diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4837abd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42d8c76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +scratch/ +*.tgz +.env +.DS_Store diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..4d7f5e0 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 120, + "semi": false, + "singleQuote": true, + "ignorePatterns": ["tools/oxlint/anti-slop/**", "**/*.md"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6692f73 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["node_modules/**", "scratch/**", "tools/oxlint/anti-slop/**"], + "jsPlugins": [ + // anti-slop, vendored from github.com/dmmulroy/anti-slop (see tools/oxlint/anti-slop/VENDOR.md). + // `@oxlint/plugins` in package.json must stay pinned to the exact same version as `oxlint`. + { "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" } + ], + "rules": { + // Every if gets braces, and a void call is never returned to save a line. + "curly": ["error", "all"], + "typescript/no-confusing-void-expression": "error", + "anti-slop/no-array-filter-map": "error", + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "off", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reduce-accumulator-copy": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-shape-in-symbol-names": "error", + "anti-slop/no-unknown-parameters": "error", + "anti-slop/no-unknown-returns": "error", + "anti-slop/no-unknown-type-aliases": "error", + "anti-slop/no-unsafe-dictionary-type": "error", + "anti-slop/no-widen-then-assert": "error", + "anti-slop/require-safety-comment-for-type-assertion": "error" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e76fc2f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 E2B + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bde4108 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# E2B extension for Pi + +Keep [Pi](https://pi.dev) on your machine and run its tools in an [E2B](https://e2b.dev) sandbox. + +With `pi --e2b`, the agent works on a copy of your project in a sandbox that belongs to the Pi session. It cannot touch your files, processes or credentials. You bring its changes back with `/e2b pull`. + +## Install + +```bash +npm install -g @earendil-works/pi-coding-agent +pi install npm:@e2b/pi +export E2B_API_KEY=e2b_*** # https://e2b.dev/dashboard?tab=keys +``` + +## Use + +```bash +cd my-project +pi --e2b +``` + +1. **Start.** Pi opens at once. The sandbox starts with your first prompt, and your git working tree goes to it: tracked files and new files, with your uncommitted changes. Files that git ignores, such as `.env`, stay on your machine. +2. **Work.** `bash`, `read`, `write`, `edit` and your `!` commands run in the sandbox. When the agent starts a server, `preview_url` gives you a public link. +3. **Pull.** `/e2b pull` shows what changed and applies it to your checkout. Or use `/e2b pull --branch` to get a commit on the local branch `pi/`. Push and open the pull request with your own git. +4. **Resume.** Quit when you want. `pi --e2b -c` gets the same sandbox back, with its files and running processes. A forked session (`/fork`, `--fork`) gets its own copy of the sandbox, so the two sessions do not share files. + +Outside a git checkout you get an empty workspace. + +| Command | | +| --- | --- | +| `/e2b` | Sandbox ID, working directory, changes not pulled yet | +| `/e2b pull` | Apply the sandbox's changes to your checkout | +| `/e2b pull --branch` | Commit them on the local branch `pi/` instead, ready for `git push` (needs a clean working tree) | +| `/e2b kill` | Kill the sandbox | + +| Flag | | +| --- | --- | +| `--e2b` | Run tools in an E2B sandbox | +| `--e2b-template ` | E2B template (default `base`) | +| `--e2b-idle ` | Idle time before the sandbox pauses (default `15`) | + +## How it works + +- **Tools.** Pi's tools take pluggable operations. The extension gives `bash`, `read`, `write` and `edit` operations that run in the sandbox, so Pi's own tool logic stays the same. `grep`, `find` and `ls` (off by default in Pi) would search your machine, so in E2B mode they are off and the agent searches with `bash`. When there is no sandbox, tools fail; they never run on your machine. +- **Background processes.** `npm run dev &` returns at once. Abort and timeout kill the whole process group. +- **Pause and resume.** The sandbox pauses with its memory after the idle time and resumes on the next tool call or on traffic to a preview URL. On quit it pauses after one minute. +- **Pull.** The sandbox keeps a baseline commit of what you uploaded. `/e2b pull` diffs the sandbox files against it, whatever the agent did with git, and runs `git apply` in your checkout. If your files changed in a conflicting way, nothing is applied. The baseline then moves forward, so the next pull brings only newer changes. +- **No credentials in the sandbox.** Nothing is cloned or pushed from the sandbox, and your environment variables are not sent. +- **Fork.** A forked session copies the parent's sandbox with E2B's sandbox fork (files and memory, in about a second). The parent keeps its sandbox. +- **Cleanup.** A sandbox is killed when no Pi session refers to it any more, for example after you delete the session. This is checked on the next start, for sandboxes from the same machine and sessions in Pi's own session folder. With `--no-session`, Pi offers to pull, then kills the sandbox on exit. + +## Development + +```bash +npm install +E2B_API_KEY=e2b_*** pi -e ./index.ts --e2b # run from source, no build step + +npm run check # typecheck, oxlint (with the vendored anti-slop rules), oxfmt, offline smoke test +npm run test:live # real sandbox: tools, background processes, abort, pause and resume, upload and pull +``` + +``` +index.ts flags, session lifecycle, /e2b command, cleanup +src/tools.ts sandbox-backed bash, read, write and edit; preview_url +src/workspace.ts upload the working tree, diff and pull changes back +``` + +## License + +MIT diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f60c003 --- /dev/null +++ b/index.ts @@ -0,0 +1,359 @@ +/** + * @e2b/pi: with `--e2b`, Pi's tools run in an E2B sandbox that belongs to the + * Pi session. Pi itself, the model calls and your files stay on your machine. + * + * - Start: Pi opens at once. The sandbox starts with your first prompt, and + * your git working tree is uploaded (ignored files stay home). + * - Work: bash, read, write and edit run in the sandbox. Idle sandboxes pause + * with their memory and resume on the next call. + * - Pull: `/e2b pull` applies the sandbox's changes to your checkout. + * - Resume: the session gets the same sandbox back. A fork gets a copy of it. + * - Cleanup: sandboxes of deleted sessions are killed; `--no-session` ones on exit. + */ + +import { createHash } from 'node:crypto' +import { readFile, realpath } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { type ExtensionAPI, type ExtensionContext, getAgentDir, SessionManager } from '@earendil-works/pi-coding-agent' +import { NotFoundError, Sandbox } from 'e2b' +import { registerTools, type Target, useSandboxTools } from './src/tools.ts' +import { changes, gitRoot, init, pull, upload } from './src/workspace.ts' + +const ENTRY = 'e2b-sandbox' +/** Sandboxes are tagged with the machine, so cleanup never touches another machine's sessions. */ +const HOST = createHash('sha256').update(`${os.hostname()}:${os.userInfo().username}`).digest('hex').slice(0, 16) +/** Cleanup leaves younger sandboxes alone: their session may not have recorded them yet. */ +const GRACE_MS = 15 * 60_000 + +/** Where the project lives in the sandbox. */ +interface Layout { + /** Working directory in the sandbox. */ + cwd: string + /** Repository root in the sandbox. */ + dir: string + /** Host checkout that `/e2b pull` writes to; unset outside a git checkout. */ + hostRoot?: string +} + +/** Recorded in the session so a resumed session finds its sandbox again. */ +interface Entry extends Layout { + sandboxId: string + /** Session that owns the sandbox. A fork inherits the parent's entry, so a different owner means "copy it". */ + owner: string +} + +interface Active extends Target, Entry {} + +/** What session_start learns; the sandbox itself starts later, on first use. */ +interface Pending { + apiKey: string + idleMs: number + layout: Layout + /** The sandbox this session (or the session it was forked from) used last. */ + prev?: Entry +} + +export default function (pi: ExtensionAPI) { + pi.registerFlag('e2b', { description: 'Run tools in an E2B sandbox', type: 'boolean' }) + pi.registerFlag('e2b-template', { description: 'E2B template (default: base)', type: 'string' }) + pi.registerFlag('e2b-idle', { description: 'Idle minutes before the sandbox pauses (default: 15)', type: 'string' }) + + let pending: Pending | undefined + let active: Active | undefined + let starting: Promise | undefined + let apiKey: string | undefined + + /** The session's sandbox. The first call starts it; calls meanwhile wait for the same start. */ + function sandbox(ctx: ExtensionContext): Promise { + if (active) { + return Promise.resolve(active) + } + if (!pending) { + return Promise.reject(new Error('E2B is not set up. Restart Pi with --e2b and E2B_API_KEY.')) + } + starting ??= start(ctx, pending).finally(() => { + starting = undefined + }) + return starting + } + + registerTools(pi, sandbox) + + pi.registerCommand('e2b', { + description: 'E2B sandbox: status | pull [--branch] | kill', + getArgumentCompletions: (prefix) => + ['status', 'pull', 'pull --branch', 'kill'].flatMap((s) => + s.startsWith(prefix.trim()) ? [{ value: s, label: s }] : [], + ), + handler: async (args, ctx) => { + const sub = args.trim() || 'status' + if (!pending) { + ctx.ui.notify('No E2B sandbox. Start Pi with --e2b.', 'warning') + return + } + // A resumed session's sandbox has not been touched yet: resume it. A new session has none to show. + if (!active && !pending.prev) { + ctx.ui.notify('No E2B sandbox yet. It starts with your first prompt.', 'info') + return + } + const a = await sandbox(ctx).catch(() => undefined) + if (!a) { + return + } + if (sub === 'pull') { + return pullChanges(ctx, a) + } + if (sub === 'pull --branch') { + return pullChanges(ctx, a, `pi/${ctx.sessionManager.getSessionId().slice(0, 8)}`) + } + if (sub === 'kill') { + if (!(await ctx.ui.confirm('Kill sandbox', `Kill ${a.sandboxId}? Changes you did not pull are lost.`))) { + return + } + active = undefined + if (pending) { + pending = { ...pending, prev: undefined } + } + await a.sandbox.kill() + status(ctx, a.cwd) + ctx.ui.notify(`Killed ${a.sandboxId}. The next tool call starts a new one.`, 'info') + return + } + const { stat } = await changes(a.sandbox, a.dir) + ctx.ui.notify(`E2B sandbox ${a.sandboxId}\ncwd: ${a.cwd}\n${stat || 'No changes to pull.'}`, 'info') + }, + }) + + /** Apply the sandbox's changes to the host checkout; with `branch`, commit them on that local branch. */ + async function pullChanges(ctx: ExtensionContext, a: Active, branch?: string): Promise { + if (!a.hostRoot) { + ctx.ui.notify('Pi was not started in a git checkout, so there is nowhere to pull to.', 'warning') + return + } + const c = await changes(a.sandbox, a.dir) + if (!c.stat) { + ctx.ui.notify('No changes to pull.', 'info') + return + } + const where = branch ? `a commit on branch ${branch} in ${a.hostRoot}` : a.hostRoot + if (!(await ctx.ui.confirm('Pull changes', `Apply to ${where}?\n\n${c.stat}`))) { + return + } + try { + await pull(a.sandbox, a.dir, a.hostRoot, c, branch) + ctx.ui.notify(branch ? `Committed on ${branch}. Push it with git push.` : 'Pulled. Review with git diff.', 'info') + } catch (err) { + ctx.ui.notify(`Pull failed: ${message(err)}`, 'error') + } + } + + /** Reconnect to the session's sandbox, fork the parent session's one, or create one and upload the project. */ + async function start(ctx: ExtensionContext, p: Pending): Promise { + const sessionId = ctx.sessionManager.getSessionId() + const saved = ctx.sessionManager.getSessionFile() !== undefined + const started = Date.now() + ctx.ui.setStatus('e2b', 'e2b: starting...') + let created: Sandbox | undefined + try { + let sb: Sandbox | undefined + let how = 'ready' + if (p.prev) { + sb = await reconnect(p, p.prev, ctx) + if (sb && p.prev.owner !== sessionId) { + sb = created = await fork(sb, p.idleMs) + how = `forked from ${p.prev.sandboxId}` + } + } + if (!sb) { + const template = pi.getFlag('e2b-template') + const opts = { + apiKey: p.apiKey, + timeoutMs: p.idleMs, + // A --no-session sandbox can never be resumed, so a crashed Pi leaves nothing behind. + lifecycle: saved ? { onTimeout: 'pause' as const, autoResume: true } : { onTimeout: 'kill' as const }, + // Only sandboxes tagged with this machine are ever cleaned up. + metadata: { 'created-by': 'pi-e2b', 'pi-session': sessionId, ...(inPiStore(ctx) ? { 'pi-host': HOST } : {}) }, + } + sb = created = template ? await Sandbox.create(String(template), opts) : await Sandbox.create(opts) + if (p.layout.hostRoot) { + ctx.ui.setStatus('e2b', 'e2b: uploading...') + await upload(sb, p.layout.hostRoot, p.layout.dir) + } else { + await init(sb, p.layout.dir) + } + } + const entry: Entry = { ...p.layout, sandboxId: sb.sandboxId, owner: sessionId } + if (created && saved) { + pi.appendEntry(ENTRY, entry) + } + active = { ...entry, sandbox: sb, idleMs: p.idleMs } + const secs = ((Date.now() - started) / 1000).toFixed(1) + ctx.ui.notify(`E2B sandbox ${sb.sandboxId} ${how} in ${secs}s.`, 'info') + status(ctx, active.cwd, sb.sandboxId) + return active + } catch (err) { + await created?.kill().catch(() => undefined) + status(ctx, p.layout.cwd, p.prev?.sandboxId) + ctx.ui.notify(`E2B: could not start the sandbox: ${message(err)}`, 'error') + throw err + } + } + + /** The recorded sandbox, resumed; undefined when it is gone. Any other error must not create a second one. */ + async function reconnect(p: Pending, prev: Entry, ctx: ExtensionContext): Promise { + try { + return await Sandbox.connect(prev.sandboxId, { apiKey: p.apiKey, timeoutMs: p.idleMs }) + } catch (err) { + if (!(err instanceof NotFoundError)) { + throw err + } + ctx.ui.notify(`E2B: sandbox ${prev.sandboxId} is gone, creating a new one.`, 'warning') + return undefined + } + } + + pi.on('session_start', async (_event, ctx) => { + if (pi.getFlag('e2b') !== true || active || starting) { + return + } + apiKey ??= process.env.E2B_API_KEY || (ctx.hasUI ? await ctx.ui.input('E2B API key', 'e2b_...') : undefined) + if (!apiKey) { + ctx.ui.notify('E2B: set E2B_API_KEY. Tools stay off until then.', 'error') + return + } + + if (inPiStore(ctx)) { + void reap(apiKey).catch(() => undefined) + } + const prev = lastEntry(ctx) + pending = { + apiKey, + idleMs: (Number(pi.getFlag('e2b-idle')) || 15) * 60_000, + layout: prev ?? (await layoutFor(ctx.sessionManager.getCwd())), + prev, + } + useSandboxTools(pi) + status(ctx, pending.layout.cwd, prev?.sandboxId) + }) + + pi.on('before_agent_start', (event, ctx) => { + if (!pending) { + return + } + // Start (or resume) the sandbox while the model works on its first reply. + if (!active) { + void sandbox(ctx).catch(() => undefined) + } + if (!event.systemPromptOptions) { + return + } + event.systemPromptOptions.cwd = `${pending.layout.cwd} (in an E2B sandbox, not on the user's machine)` + event.systemPromptOptions.promptGuidelines = [ + ...(event.systemPromptOptions.promptGuidelines ?? []), + 'You work in a copy of the project in an E2B sandbox. The user brings your file changes back with /e2b pull. ' + + 'Use bash (grep, rg, find, ls) to search. Servers must listen on 0.0.0.0; use preview_url to share them.', + ] + }) + + pi.on('session_shutdown', async (event, ctx) => { + const a = active ?? (await starting?.catch(() => undefined)) + active = undefined + pending = undefined + ctx.ui.setStatus('e2b', undefined) + if (!a) { + return + } + if (ctx.sessionManager.getSessionFile() !== undefined) { + // Kept for resume. On quit, pause in a minute instead of after the full idle time. + if (event.reason !== 'reload') { + await a.sandbox.setTimeout(60_000).catch(() => undefined) + } + return + } + // --no-session: this sandbox can never be resumed. Offer the changes, then kill it. + if (ctx.hasUI && a.hostRoot) { + await pullChanges(ctx, a).catch(() => undefined) + } + await a.sandbox.kill().catch(() => undefined) + }) +} + +/** A copy of `parent` with its files and running processes. The parent keeps running. */ +async function fork(parent: Sandbox, idleMs: number): Promise { + const [copy] = await Sandbox.fork(parent.sandboxId, { timeoutMs: idleMs }) + // The parent was resumed only to be copied; let it pause again soon. + await parent.setTimeout(60_000).catch(() => undefined) + if (!copy) { + throw new Error('E2B returned no fork') + } + if (copy instanceof Error) { + throw copy + } + return copy +} + +/** The sandbox mirrors the host checkout, so the agent sees the same subdirectory you started Pi in. */ +async function layoutFor(startDir: string): Promise { + // git prints the resolved path (/private/var/... on macOS), so resolve symlinks here too. + const hostCwd = await realpath(startDir) + const hostRoot = await gitRoot(hostCwd) + const dir = `/home/user/${path.basename(hostRoot ?? 'workspace').replace(/[^\w.-]/g, '_')}` + const cwd = hostRoot ? path.posix.join(dir, path.relative(hostRoot, hostCwd)) : dir + return { cwd, dir, hostRoot } +} + +function status(ctx: ExtensionContext, cwd: string, sandboxId?: string): void { + const id = sandboxId ? sandboxId.slice(0, 8) : 'new' + ctx.ui.setStatus('e2b', ctx.ui.theme.fg('accent', `e2b: ${id} ${cwd}`)) +} + +function lastEntry(ctx: ExtensionContext): Entry | undefined { + const e = ctx.sessionManager.getEntries().findLast((e) => e.type === 'custom' && e.customType === ENTRY) + // SAFETY: entries of this custom type are written only by this extension, always as Entry. + return e?.type === 'custom' ? (e.data as Entry) : undefined +} + +// `catch` gives `unknown`; this turns it into text for the user. +// oxlint-disable-next-line anti-slop/no-unknown-parameters +const message = (err: unknown) => (err instanceof Error ? err.message : String(err)) + +/** + * A saved session in Pi's own session store, the one SessionManager.listAll() + * reads. Cleanup trusts only that store, so sandboxes of sessions kept + * elsewhere (--session-dir, --no-session) are never cleaned up by it. + */ +function inPiStore(ctx: ExtensionContext): boolean { + if (ctx.sessionManager.getSessionFile() === undefined) { + return false + } + return path.dirname(ctx.sessionManager.getSessionDir()) === path.join(getAgentDir(), 'sessions') +} + +/** + * Kill this machine's sandboxes that no Pi session refers to any more. Pi has + * no "session deleted" event, so read the sessions that still exist. Forks + * copy the parent's E2B metadata, so the session files are the only reliable + * owner record. + */ +async function reap(apiKey: string): Promise { + const kept = new Set() + for (const s of await SessionManager.listAll()) { + // A session that cannot be read may still own a sandbox: then kill nothing. + const text = await readFile(s.path, 'utf8') + for (const m of text.matchAll(/"sandboxId":"([^"]+)"/g)) { + if (m[1]) { + kept.add(m[1]) + } + } + } + const pages = Sandbox.list({ apiKey, query: { metadata: { 'created-by': 'pi-e2b', 'pi-host': HOST } } }) + while (pages.hasNext) { + for (const s of await pages.nextItems()) { + if (kept.has(s.sandboxId) || Date.now() - s.startedAt.getTime() < GRACE_MS) { + continue + } + await Sandbox.kill(s.sandboxId, { apiKey }).catch(() => undefined) + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..08a8d13 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3306 @@ +{ + "name": "@e2b/pi", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@e2b/pi", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "e2b": "^2.55.0" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "^1.1.0", + "@oxlint/plugins": "1.87.0", + "@types/node": "^26.6.5", + "oxfmt": "0.72.0", + "oxlint": "1.87.0", + "oxlint-tsgolint": "7.0.2004", + "tsx": "^4.23.15", + "typebox": "1.3.27", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=0.80.0", + "typebox": "^1" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.2.tgz", + "integrity": "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.73.tgz", + "integrity": "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.75.tgz", + "integrity": "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.18.tgz", + "integrity": "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-login": "^3.972.80", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.80.tgz", + "integrity": "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.85", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.85.tgz", + "integrity": "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-ini": "^3.973.18", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.73.tgz", + "integrity": "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.17.tgz", + "integrity": "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/token-providers": "3.1149.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1149.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1149.0.tgz", + "integrity": "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.79.tgz", + "integrity": "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.36.tgz", + "integrity": "sha512-HFaKXA6ugPVvpR7W/3UT3hylWIPndqGMwduwNVrmGsRT5+OESHN8/ven4eeofraQF5Kxp8A8QgGVrv7iQsTRKQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.31.tgz", + "integrity": "sha512-4yvdTVwPGKCnrwuwlYEmwLqQXKwk0unx3f/FvBi4FA9ruK/WGoPJyqfK7hOYurheIyQL44FR0ovgOHqvkfdpAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.55.tgz", + "integrity": "sha512-dHlEl3sCn1Cu+qddPoz5RxjgGCXEzs/pbrh/rDRaOeb/Tz//FF59hacDvy7kvUn19mkm2nQRjhpzBnX+Ye37Qw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.47.tgz", + "integrity": "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.2.0.tgz", + "integrity": "sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.2.0.tgz", + "integrity": "sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.2.0" + } + }, + "node_modules/@e2b/dockerfile-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@e2b/dockerfile-utils/-/dockerfile-utils-0.1.0.tgz", + "integrity": "sha512-JBiWrLHESy6E08PTGLf0RlE0BCKyi5+Et6TzX8pQOz3GRl/B89EnR3PZaIvp15QgD8STMY92D9KEHQqrOdWRKQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.18.1 <21 || >=22" + } + }, + "node_modules/@earendil-works/chord": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-1.1.0.tgz", + "integrity": "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.1.0.tgz", + "integrity": "sha512-aX1KZomNCPmwYnXa3OivF3VYLJ+WPUkIJlEIZTgwdOZdY/+oToWTQ334WYpQeOz9POpiYFNMJLPwIhXQ4e48kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^1.1.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.1.0.tgz", + "integrity": "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.1.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.1.0.tgz", + "integrity": "sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.1.0.tgz", + "integrity": "sha512-SeEi/4hdcHNgA9UWlefZl7ZZpm3dzi2OoxNjDHsBJ9o298LNOtbL4DGKgitlEj6uCTccvtw6f2hlCkTPVJ2RXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.1.0", + "@earendil-works/pi-agent-core": "^1.1.0", + "@earendil-works/pi-ai": "^1.1.0", + "@earendil-works/pi-codemode": "^1.1.0", + "@earendil-works/pi-mcp": "^1.1.0", + "@earendil-works/pi-tui": "^1.1.0", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.1.0.tgz", + "integrity": "sha512-xGKwvu3SvVTeoIx8Z7UGoJprB4VK20wKORMhufcWGx61fIauhu3J+UqfB5eeoF1oE/geYBA598K6nh1SdABQkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.1.0.tgz", + "integrity": "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.1.0.tgz", + "integrity": "sha512-v7wkS0y2ErZvZkSfemqd9RrBZJ5x6p8Ujsv7NdJj01IXJyFM0kNL6rMdcKcvTe7EVTd6ugvDB9VPZSoaBF9QxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.72.0.tgz", + "integrity": "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.72.0.tgz", + "integrity": "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.72.0.tgz", + "integrity": "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.72.0.tgz", + "integrity": "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.72.0.tgz", + "integrity": "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.72.0.tgz", + "integrity": "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.72.0.tgz", + "integrity": "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.72.0.tgz", + "integrity": "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.72.0.tgz", + "integrity": "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.72.0.tgz", + "integrity": "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.72.0.tgz", + "integrity": "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.72.0.tgz", + "integrity": "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.72.0.tgz", + "integrity": "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.72.0.tgz", + "integrity": "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.72.0.tgz", + "integrity": "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.72.0.tgz", + "integrity": "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.72.0.tgz", + "integrity": "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.72.0.tgz", + "integrity": "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz", + "integrity": "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint-tsgolint/darwin-arm64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-7.0.2004.tgz", + "integrity": "sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/darwin-x64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-7.0.2004.tgz", + "integrity": "sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint-tsgolint/linux-arm64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-7.0.2004.tgz", + "integrity": "sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/linux-x64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-7.0.2004.tgz", + "integrity": "sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint-tsgolint/win32-arm64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-7.0.2004.tgz", + "integrity": "sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint-tsgolint/win32-x64": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2004.tgz", + "integrity": "sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.87.0.tgz", + "integrity": "sha512-OtSZ6NxNkS7bkVy7zk9zGnajz2hVIu0uRPP/dJvJesGRaXtimpA4lpxap7FaIJndG2UIoKhnBU4DxyDQIWv6yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.35.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.2.tgz", + "integrity": "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/e2b": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-2.55.0.tgz", + "integrity": "sha512-viC7GLHMPqzilndlKgNXWN0ebMV8fpbI1IfDlPd+GHuNcJIcUCXKyr5HAxjcC5sDMym3rrbyUyUQ+zQVAU9oGA==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.15.0", + "@connectrpc/connect": "^2.2.0", + "@connectrpc/connect-web": "^2.2.0", + "@e2b/dockerfile-utils": "^0.1.0", + "chalk": "^5.6.2", + "compare-versions": "^6.1.1", + "glob": "^13.0.6", + "openapi-fetch": "^0.17.0", + "platform": "^1.3.6", + "tar": "^7.5.22", + "undici": "^7.30.0" + }, + "engines": { + "node": ">=20.18.1 <21 || >=22" + }, + "optionalDependencies": { + "undici8": "npm:undici@8.11.2" + } + }, + "node_modules/e2b/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/e2b/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, + "node_modules/oxfmt": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", + "integrity": "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.72.0", + "@oxfmt/binding-android-arm64": "0.72.0", + "@oxfmt/binding-darwin-arm64": "0.72.0", + "@oxfmt/binding-darwin-x64": "0.72.0", + "@oxfmt/binding-freebsd-x64": "0.72.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", + "@oxfmt/binding-linux-arm64-gnu": "0.72.0", + "@oxfmt/binding-linux-arm64-musl": "0.72.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-musl": "0.72.0", + "@oxfmt/binding-linux-s390x-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-musl": "0.72.0", + "@oxfmt/binding-openharmony-arm64": "0.72.0", + "@oxfmt/binding-win32-arm64-msvc": "0.72.0", + "@oxfmt/binding-win32-ia32-msvc": "0.72.0", + "@oxfmt/binding-win32-x64-msvc": "0.72.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint-tsgolint": { + "version": "7.0.2004", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-7.0.2004.tgz", + "integrity": "sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ==", + "dev": true, + "license": "MIT", + "bin": { + "tsgolint": "bin/tsgolint.js" + }, + "optionalDependencies": { + "@oxlint-tsgolint/darwin-arm64": "7.0.2004", + "@oxlint-tsgolint/darwin-x64": "7.0.2004", + "@oxlint-tsgolint/linux-arm64": "7.0.2004", + "@oxlint-tsgolint/linux-x64": "7.0.2004", + "@oxlint-tsgolint/win32-arm64": "7.0.2004", + "@oxlint-tsgolint/win32-x64": "7.0.2004" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici8": { + "name": "undici", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..44305e5 --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "@e2b/pi", + "version": "0.1.0", + "description": "Pi coding agent extension that runs every tool call in an E2B sandbox while the agent runs on your machine", + "keywords": [ + "e2b", + "pi-extension", + "pi-package", + "sandbox" + ], + "homepage": "https://e2b.dev/docs/agents/pi", + "bugs": { + "url": "https://github.com/e2b-dev/pi-extension/issues" + }, + "license": "MIT", + "author": "E2B", + "repository": { + "type": "git", + "url": "git+https://github.com/e2b-dev/pi-extension.git" + }, + "files": [ + "index.ts", + "src", + "README.md", + "LICENSE" + ], + "type": "module", + "scripts": { + "typecheck": "tsc -p .", + "lint": "oxlint --type-aware index.ts src scripts", + "format": "oxfmt index.ts src scripts package.json tsconfig.json", + "format:check": "oxfmt --check index.ts src scripts package.json tsconfig.json", + "smoke": "tsx scripts/smoke.ts", + "test:live": "tsx scripts/live.ts", + "check": "npm run typecheck && npm run lint && npm run format:check && npm run smoke" + }, + "dependencies": { + "e2b": "^2.55.0" + }, + "devDependencies": { + "@earendil-works/pi-coding-agent": "^1.1.0", + "@oxlint/plugins": "1.87.0", + "@types/node": "^26.6.5", + "oxfmt": "0.72.0", + "oxlint": "1.87.0", + "oxlint-tsgolint": "7.0.2004", + "tsx": "^4.23.15", + "typebox": "1.3.27", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": ">=0.80.0", + "typebox": "^1" + }, + "engines": { + "node": ">=22" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/scripts/live.ts b/scripts/live.ts new file mode 100644 index 0000000..e0b519f --- /dev/null +++ b/scripts/live.ts @@ -0,0 +1,280 @@ +/** + * Live test against a real E2B sandbox (needs E2B_API_KEY). Drives the tools + * the way Pi does, and moves a real git checkout up and back. + * + * E2B_API_KEY=... npm run test:live + */ + +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ToolDefinition } from '@earendil-works/pi-coding-agent' +import { Sandbox } from 'e2b' +import extension from '../index.ts' +import { registerTools, type Target } from '../src/tools.ts' +import { changes, pull, upload } from '../src/workspace.ts' + +const apiKey = process.env.E2B_API_KEY +if (!apiKey) { + throw new Error('Set E2B_API_KEY') +} + +async function step(name: string, fn: () => Promise) { + const t = Date.now() + await fn().catch((err) => { + console.log(`FAIL ${name}`) + throw err + }) + console.log(`ok ${name} (${Date.now() - t} ms)`) +} + +// A host checkout with a committed file, an uncommitted change and an ignored secret. +const host = mkdtempSync(path.join(os.tmpdir(), 'pi-e2b-live-')) +const git = (...args: string[]) => execFileSync('git', args, { cwd: host }).toString() +git('init', '-q') +writeFileSync(path.join(host, 'app.ts'), 'export const v = 1\n') +writeFileSync(path.join(host, '.gitignore'), '.env\n') +git('add', '-A') +git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'init') +writeFileSync(path.join(host, 'app.ts'), 'export const v = 2 // uncommitted\n') +writeFileSync(path.join(host, '.env'), 'SECRET=1\n') + +const sandbox = await Sandbox.create({ + apiKey, + timeoutMs: 5 * 60_000, + lifecycle: { onTimeout: 'pause', autoResume: true }, +}) +const dir = '/home/user/proj' +/** Sandboxes the lifecycle test started, killed at the end. */ +const started = new Set() +const run = Date.now().toString(36) +const [sessionA, sessionB] = [`live-a-${run}`, `live-b-${run}`] +const target: Target = { sandbox, cwd: dir, idleMs: 5 * 60_000 } + +// Register the tools against a stub Pi with --e2b on, and keep them by name. +const tools = new Map() +let blockToolCall: (event: { toolName: string }) => { block: boolean } | undefined = () => undefined +const pi = { + getFlag: () => true, + registerTool: (t: ToolDefinition) => tools.set(t.name, t), + on: (event: string, handler: typeof blockToolCall) => { + if (event === 'tool_call') { + blockToolCall = handler + } + }, +} +// SAFETY: registerTools only calls the members stubbed above. +registerTools(pi as never, async () => target) +// SAFETY: the built-in tools read only `cwd` and the session id and file from the context. +const ctx = { + cwd: process.cwd(), + sessionManager: { getSessionId: () => 'test', getSessionFile: () => undefined }, +} as never +/** Union of the tool parameters this test uses. */ +interface ToolParams { + path?: string + content?: string + command?: string + timeout?: number + pattern?: string + edits?: { oldText: string; newText: string }[] +} +const call = async (name: string, params: ToolParams, signal?: AbortSignal) => { + const r = await tools.get(name)?.execute('id', params, signal, undefined, ctx) + return (r?.content ?? []).map((c) => (c.type === 'text' ? c.text : '')).join('\n') +} +const alive = (args: string) => `ps -eo args= | grep -qx '${args}' && echo alive || echo none` + +try { + await step('upload: working tree up, ignored files stay home', async () => { + await upload(sandbox, host, dir) + assert.match(await call('read', { path: 'app.ts' }), /v = 2 \/\/ uncommitted/) + assert.doesNotMatch(await call('bash', { command: 'ls -A' }), /\.env/) + }) + + await step('write, edit, read', async () => { + await call('write', { path: 'src/new.ts', content: 'export const n = 1\n' }) + await call('edit', { path: 'app.ts', edits: [{ oldText: 'v = 2', newText: 'v = 3' }] }) + assert.match(await call('read', { path: 'app.ts' }), /v = 3/) + }) + + await step('bash: output, cwd, exit code', async () => { + assert.match(await call('bash', { command: 'pwd; echo err >&2' }), /\/home\/user\/proj[\s\S]*err/) + const failed = await call('bash', { command: 'exit 3' }).catch((e: Error) => e.message) + assert.match(failed, /code 3/) + }) + + await step('bash: a background server does not hang the call', async () => { + await call('bash', { command: 'python3 -m http.server 8000 >/dev/null 2>&1 & sleep 1' }) + assert.match(await call('bash', { command: 'curl -s -o /dev/null -w "%{http_code}" localhost:8000' }), /200/) + }) + + await step('bash: timeout and abort kill the process group', async () => { + await assert.rejects(call('bash', { command: 'sleep 30', timeout: 2 }), /timed out/) + const ac = new AbortController() + setTimeout(() => { + ac.abort() + }, 1500) + await assert.rejects(call('bash', { command: 'sleep 40' }, ac.signal), /abort/i) + await new Promise((r) => setTimeout(r, 2500)) + assert.match(await call('bash', { command: `${alive('sleep 30')}; ${alive('sleep 40')}` }), /none\s+none/) + }) + + await step('grep, find and ls are blocked, other tools are not', async () => { + for (const toolName of ['grep', 'find', 'ls']) { + assert.equal(blockToolCall({ toolName })?.block, true) + } + assert.equal(blockToolCall({ toolName: 'bash' }), undefined) + }) + + await step('a paused sandbox resumes on the next call, with its processes', async () => { + await sandbox.pause() + assert.match(await call('bash', { command: 'curl -s -o /dev/null -w "%{http_code}" localhost:8000' }), /200/) + }) + + await step('pull: sandbox changes land in the host checkout, once', async () => { + await call('bash', { command: 'git add -A && git -c user.name=a -c user.email=a@a commit -qm agent' }) + const c = await changes(sandbox, dir) + assert.match(c.stat, /app\.ts[\s\S]*src\/new\.ts/) + await pull(sandbox, dir, host, c) + assert.match(readFileSync(path.join(host, 'app.ts'), 'utf8'), /v = 3/) + assert.equal(readFileSync(path.join(host, 'src/new.ts'), 'utf8'), 'export const n = 1\n') + assert.equal((await changes(sandbox, dir)).stat, '', 'nothing left to pull') + }) + + await step('pull --branch: refuses a dirty tree, then commits only the agent changes', async () => { + await call('write', { path: 'src/new.ts', content: 'export const n = 2\n' }) + // The previous pull left the host tree dirty. + await assert.rejects(pull(sandbox, dir, host, await changes(sandbox, dir), 'pi/test'), /commit or stash/) + git('add', '-A') + git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'first pull') + await pull(sandbox, dir, host, await changes(sandbox, dir), 'pi/test') + assert.equal(git('branch', '--show-current').trim(), 'pi/test') + assert.equal(git('show', '--name-only', '--format=', 'HEAD').trim(), 'src/new.ts') + assert.equal(git('status', '--porcelain'), '') + + await call('write', { path: 'src/new.ts', content: 'export const n = 3\n' }) + await pull(sandbox, dir, host, await changes(sandbox, dir), 'pi/test') + assert.equal(git('rev-list', '--count', 'HEAD~2..HEAD').trim(), '2', 'second pull is a second commit on the branch') + }) + + await step('session: lazy start, resume, and a fork gets its own copy', async () => { + await lifecycle() + }) +} finally { + await sandbox.kill().catch(() => undefined) + for (const session of [sessionA, sessionB]) { + const pages = Sandbox.list({ apiKey, query: { metadata: { 'pi-session': session } } }) + while (pages.hasNext) { + for (const s of await pages.nextItems()) { + started.add(s.sandboxId) + } + } + } + for (const id of started) { + await Sandbox.kill(id, { apiKey }).catch(() => undefined) + } +} +console.log('all live checks passed') + +/** Drive the whole extension through a stub Pi: one session, a resume, then a fork of it. */ +async function lifecycle() { + interface Custom { + type: 'custom' + customType: string + data: { sandboxId: string; owner: string; cwd: string } + } + interface SessionEvent { + reason: string + } + type Handler = (event: SessionEvent, ctx: ReturnType) => Promise | void + const handlers = new Map() + const extTools = new Map() + let entries: Custom[] = [] + let sessionId = sessionA + const notes: string[] = [] + let command: ((args: string, ctx: ReturnType) => Promise) | undefined + const piStub = { + registerFlag: () => undefined, + getFlag: (name: string) => (name === 'e2b' ? true : undefined), + registerTool: (t: ToolDefinition) => extTools.set(t.name, t), + registerCommand: (_name: string, opts: { handler: typeof command }) => { + command = opts.handler + }, + on: (event: string, handler: Handler) => handlers.set(event, handler), + getActiveTools: () => ['read', 'bash', 'edit', 'write'], + setActiveTools: () => undefined, + appendEntry: (customType: string, data: Custom['data']) => entries.push({ type: 'custom', customType, data }), + } + // SAFETY: the extension only calls the members stubbed above. + extension(piStub as never) + const ui = { + setStatus: () => undefined, + notify: (text: string) => notes.push(text), + theme: { fg: (_color: string, text: string) => text }, + } + const sessionCtx = () => ({ + cwd: host, + hasUI: false, + ui, + sessionManager: { + getSessionId: () => sessionId, + // Outside Pi's session store, so cleanup never runs and the sandboxes are not tagged for it. + getSessionFile: () => '/nonexistent/live.jsonl', + getSessionDir: () => '/nonexistent', + getCwd: () => host, + getEntries: () => entries, + }, + }) + const bash = async (command: string) => { + // SAFETY: the tools read only cwd, ui and the session manager members stubbed in sessionCtx. + const r = await extTools.get('bash')?.execute('id', { command }, undefined, undefined, sessionCtx() as never) + return (r?.content ?? []).map((c) => (c.type === 'text' ? c.text : '')).join('\n') + } + const sandboxesOf = async (session: string) => { + const pages = Sandbox.list({ apiKey, query: { metadata: { 'pi-session': session } } }) + const ids: string[] = [] + while (pages.hasNext) { + for (const s of await pages.nextItems()) { + ids.push(s.sandboxId) + } + } + return ids + } + const emit = (event: string, payload: SessionEvent) => handlers.get(event)?.(payload, sessionCtx()) + + // Session A: no sandbox until the first tool call. + await emit('session_start', { reason: 'startup' }) + assert.deepEqual(await sandboxesOf(sessionA), [], 'session_start does not create a sandbox') + assert.match(await bash('pwd'), /\/home\/user\/pi-e2b-live-/) + const a = entries.at(-1)?.data + assert.ok(a && a.owner === sessionA, 'the sandbox is recorded for session A') + started.add(a.sandboxId) + await bash('echo from-a > note.txt; python3 -m http.server 8001 >/dev/null 2>&1 & sleep 1') + await emit('session_shutdown', { reason: 'quit' }) + + // Resume A: the same sandbox, and no new entry. /e2b resumes it before any tool call. + await emit('session_start', { reason: 'resume' }) + await command?.('', sessionCtx()) + assert.match(notes.at(-1) ?? '', new RegExp(`E2B sandbox ${a.sandboxId}\\ncwd:`)) + assert.match(await bash('cat note.txt'), /from-a/) + assert.equal(entries.length, 1) + await emit('session_shutdown', { reason: 'quit' }) + + // Fork A into B: B inherits A's entries and gets a copy, with files and running processes. + sessionId = sessionB + entries = [...entries] + await emit('session_start', { reason: 'fork' }) + assert.match(await bash('cat note.txt; curl -s -o /dev/null -w "%{http_code}" localhost:8001'), /from-a[\s\S]*200/) + const b = entries.at(-1)?.data + assert.ok(b && b.owner === sessionB && b.sandboxId !== a.sandboxId, 'the fork has its own sandbox') + started.add(b.sandboxId) + await bash('echo from-b > note.txt') + await emit('session_shutdown', { reason: 'quit' }) + + // A is untouched by B. + const sbA = await Sandbox.connect(a.sandboxId, { apiKey }) + assert.equal((await sbA.files.read(`${a.cwd}/note.txt`)).trim(), 'from-a') +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..9f99503 --- /dev/null +++ b/scripts/smoke.ts @@ -0,0 +1,31 @@ +/** + * Offline smoke test: load the extension with a stub Pi API and check what it + * registers. Needs no API key and no network. + */ + +import assert from 'node:assert/strict' +import extension from '../index.ts' + +const flags: string[] = [] +const tools = new Map() +const commands: string[] = [] +const events: string[] = [] + +const pi = { + registerFlag: (name: string) => flags.push(name), + getFlag: () => undefined, + registerTool: (tool: { name: string; defaultActive?: boolean }) => tools.set(tool.name, tool), + registerCommand: (name: string) => commands.push(name), + on: (event: string) => events.push(event), +} + +// SAFETY: at load time the extension only calls the members stubbed above; handlers are registered, not run. +extension(pi as never) + +assert.deepEqual(flags.sort(), ['e2b', 'e2b-idle', 'e2b-template']) +assert.deepEqual([...tools.keys()].sort(), ['bash', 'edit', 'preview_url', 'read', 'write']) +assert.equal(tools.get('preview_url')?.defaultActive, false, 'preview_url stays hidden until a sandbox is up') +assert.deepEqual(commands, ['e2b']) +assert.deepEqual(events.sort(), ['before_agent_start', 'session_shutdown', 'session_start', 'tool_call', 'user_bash']) + +console.log('smoke ok') diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..91608e8 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,258 @@ +/** + * Sandbox-backed versions of Pi's default tools: bash, read, write and edit. + * + * Pi's tool factories take pluggable operations, so each tool keeps Pi's own + * logic (truncation, edit matching, rendering) and only the I/O goes to E2B. + * grep, find and ls are off by default in Pi and always search the host. In E2B + * mode they are turned off and blocked; the agent searches with bash instead. + */ + +import os from 'node:os' +import path from 'node:path' +import { + type BashOperations, + type ExtensionContext, + createBashToolDefinition, + createEditToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, + type ExtensionAPI, + type ReadOperations, + type ToolDefinition, + type WriteOperations, +} from '@earendil-works/pi-coding-agent' +import { CommandExitError, type Sandbox } from 'e2b' +import { type TSchema, Type } from 'typebox' + +export interface Target { + sandbox: Sandbox + /** Working directory in the sandbox. */ + cwd: string + /** Idle time before E2B pauses the sandbox. Every tool call restarts it. */ + idleMs: number +} + +const HOST_ONLY_TOOLS = ['grep', 'find', 'ls'] +const PREVIEW_TOOL = 'preview_url' + +/** With --e2b, `sandbox` gives the session's sandbox, starting it on the first call. */ +export function registerTools(pi: ExtensionAPI, sandbox: (ctx: ExtensionContext) => Promise): void { + const e2b = () => pi.getFlag('e2b') === true + const target = async (ctx: ExtensionContext): Promise => { + const t = await sandbox(ctx).catch((err: Error) => { + throw new Error(`The E2B sandbox is not available, so nothing ran on your machine. ${err.message}`) + }) + keepAlive(t) + return t + } + + const localCwd = process.cwd() + const route =

( + local: ToolDefinition, + remote: (t: Target) => ToolDefinition, + ): ToolDefinition => ({ + ...local, + async execute(id, params, signal, onUpdate, ctx) { + if (!e2b()) { + return local.execute(id, params, signal, onUpdate, ctx) + } + const t = await target(ctx) + // Pi resolves paths against ctx.cwd, which is the host cwd. Swap in the sandbox cwd. + const sandboxCtx: typeof ctx = Object.create(ctx, { cwd: { value: t.cwd } }) + return remote(t).execute(id, params, signal, onUpdate, sandboxCtx) + }, + }) + + pi.registerTool( + route(createBashToolDefinition(localCwd), (t) => createBashToolDefinition(t.cwd, { operations: bashOps(t) })), + ) + pi.registerTool( + route(createReadToolDefinition(localCwd), (t) => createReadToolDefinition(t.cwd, { operations: readOps(t) })), + ) + pi.registerTool( + route(createWriteToolDefinition(localCwd), (t) => createWriteToolDefinition(t.cwd, { operations: writeOps(t) })), + ) + const edit = route(createEditToolDefinition(localCwd), (t) => + createEditToolDefinition(t.cwd, { operations: { ...readOps(t), ...writeOps(t) } }), + ) + const renderEditCall = edit.renderCall + pi.registerTool({ + ...edit, + // Pi previews an edit by reading the file from the host disk, where sandbox files do not + // exist. Skip that preview in E2B mode; the real diff still shows when the edit finishes. + renderCall: renderEditCall + ? (args, theme, context) => + renderEditCall(args, theme, e2b() ? Object.create(context, { argsComplete: { value: false } }) : context) + : undefined, + }) + + // Blocked even if the user turns them back on: they would read the host, not the sandbox. + pi.on('tool_call', (event) => { + if (!e2b() || !HOST_ONLY_TOOLS.includes(event.toolName)) { + return undefined + } + return { block: true, reason: `${event.toolName} would search the user's machine, not the sandbox. Use bash.` } + }) + + pi.registerTool({ + name: PREVIEW_TOOL, + label: 'Preview URL', + description: + 'Get the public URL of a port served in the E2B sandbox. Use it after you start a server, ' + + 'so the user gets a link. The server must listen on 0.0.0.0.', + defaultActive: false, + parameters: Type.Object({ port: Type.Integer({ minimum: 1, maximum: 65535 }) }), + async execute(_id, { port }, _signal, _onUpdate, ctx) { + const t = await target(ctx) + return { content: [{ type: 'text', text: `https://${t.sandbox.getHost(port)}` }], details: undefined } + }, + }) + + // `!` commands. Pi passes the host cwd here, so pin the sandbox cwd. + pi.on('user_bash', async (_event, ctx) => { + if (!e2b()) { + return undefined + } + try { + const t = await target(ctx) + return { operations: bashOps(t, t.cwd) } + } catch (err) { + // SAFETY: target() only rejects with the Error it builds above. + return { result: { output: (err as Error).message, exitCode: 1, cancelled: false, truncated: false } } + } + }) +} + +/** Switch the active tools to E2B mode: add preview_url, drop the tools that would search the host. */ +export function useSandboxTools(pi: ExtensionAPI): void { + const active = pi.getActiveTools().filter((name) => !HOST_ONLY_TOOLS.includes(name)) + pi.setActiveTools([...active, PREVIEW_TOOL]) +} + +/** Restart the idle timer. A paused sandbox rejects this, but the tool call itself resumes it. */ +function keepAlive(t: Target): void { + t.sandbox.setTimeout(t.idleMs).catch(() => undefined) +} + +/** + * E2B returns from a command when its output pipes close, and `npm run dev &` + * keeps them open. So the command runs in its own process group (setsid) with + * output in a file, and `tail --pid` streams that file until the foreground + * process exits. The process group id goes to a file named after the wrapper's + * pid, so abort and timeout can kill the whole group. + */ +const WRAPPER = [ + 'cmd=$PI_E2B_COMMAND; unset PI_E2B_COMMAND', + 'out=$(mktemp)', + 'setsid bash -c "$cmd" >"$out" 2>&1 /tmp/pi-e2b-$$.pgid', + 'tail -s 0.1 -n +1 --pid="$pid" -f "$out"', + 'wait "$pid"; rc=$?', + 'rm -f "$out" /tmp/pi-e2b-$$.pgid', + 'exit "$rc"', +].join('\n') + +/** The host env is not forwarded: it would leak host secrets into the sandbox. */ +function bashOps(t: Target, cwdOverride?: string): BashOperations { + return { + async exec(command, cwd, { onData, signal, timeout }) { + if (signal?.aborted) { + throw new Error('aborted') + } + const handle = await t.sandbox.commands.run(WRAPPER, { + background: true, + cwd: toSandboxPath(cwdOverride ?? cwd), + envs: { PI_E2B_COMMAND: command }, + timeoutMs: 0, + onStdout: (data) => { + onData(Buffer.from(data)) + }, + onStderr: (data) => { + onData(Buffer.from(data)) + }, + }) + + let stopped: Error | undefined + const stop = (reason: Error) => { + stopped ??= reason + const pgid = `$(cat /tmp/pi-e2b-${handle.pid}.pgid)` + t.sandbox.commands + .run(`kill -TERM -- -${pgid}; sleep 1; kill -KILL -- -${pgid}`, { timeoutMs: 10_000 }) + .catch(() => undefined) + .finally(() => handle.kill().catch(() => undefined)) + } + const onAbort = () => { + stop(new Error('aborted')) + } + signal?.addEventListener('abort', onAbort, { once: true }) + const timer = timeout + ? setTimeout(() => { + stop(new Error(`timeout:${timeout}`)) + }, timeout * 1000) + : undefined + // Long commands must not hit the idle timeout. + const heartbeat = setInterval(() => { + keepAlive(t) + }, 60_000) + + try { + const { exitCode } = await handle.wait() + if (stopped) { + throw stopped + } + return { exitCode } + } catch (err) { + if (stopped) { + throw stopped + } + if (err instanceof CommandExitError) { + return { exitCode: err.exitCode } + } + throw err + } finally { + clearTimeout(timer) + clearInterval(heartbeat) + signal?.removeEventListener('abort', onAbort) + } + }, + } +} + +const IMAGE_TYPES = new Map([ + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.gif', 'image/gif'], + ['.webp', 'image/webp'], +]) + +function readOps(t: Target): ReadOperations { + return { + readFile: async (p) => Buffer.from(await t.sandbox.files.read(toSandboxPath(p), { format: 'bytes' })), + access: async (p) => { + if (!(await t.sandbox.files.exists(toSandboxPath(p)))) { + throw new Error(`ENOENT: no such file '${p}'`) + } + }, + detectImageMimeType: async (p) => IMAGE_TYPES.get(path.posix.extname(p).toLowerCase()) ?? null, + } +} + +function writeOps(t: Target): WriteOperations { + return { + // files.write creates missing parent directories. + writeFile: async (p, content) => { + await t.sandbox.files.write(toSandboxPath(p), content) + }, + mkdir: async (dir) => { + await t.sandbox.files.makeDir(toSandboxPath(dir)) + }, + } +} + +/** Pi expands `~` to the HOST home directory. Point such paths at the sandbox home. */ +function toSandboxPath(p: string): string { + const hostHome = os.homedir() + return p === hostHome || p.startsWith(`${hostHome}/`) ? `/home/user${p.slice(hostHome.length)}` : p +} diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 0000000..eb6627e --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,129 @@ +/** + * Moves the user's working tree into the sandbox, and the agent's changes back. + * + * Up: the files git sees (tracked, plus untracked that are not ignored) go up as + * one tarball, so ignored files such as `.env` stay on the host. In the sandbox + * they become a fresh repository whose first commit is the baseline. + * + * Down: the sandbox tree is diffed against the baseline, whatever the agent did + * with git meanwhile, and the patch is applied to the host checkout. The + * baseline then moves forward, so the next pull brings only newer changes. + * + * No git credentials are involved: nothing is cloned or pushed. + */ + +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import type { Sandbox } from 'e2b' + +const BASE_REF = 'refs/pi-e2b/base' +const AGENT = '-c user.name="Pi agent" -c user.email=pi-agent@localhost' + +/** Run a host command; resolves with stdout, rejects with stderr. */ +function run(cmd: string, args: string[], opts: { cwd: string; input?: Buffer | string }): Promise { + return new Promise((resolve, reject) => { + // COPYFILE_DISABLE stops macOS tar from adding `._*` metadata files. + const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, COPYFILE_DISABLE: '1' } }) + const out: Buffer[] = [] + const err: Buffer[] = [] + child.stdout.on('data', (d: Buffer) => out.push(d)) + child.stderr.on('data', (d: Buffer) => err.push(d)) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(Buffer.concat(out)) + } else { + reject(new Error(Buffer.concat(err).toString().trim())) + } + }) + child.stdin.end(opts.input) + }) +} + +/** Root of the git checkout that contains `cwd`, or undefined. */ +export async function gitRoot(cwd: string): Promise { + return run('git', ['rev-parse', '--show-toplevel'], { cwd }).then( + (out) => out.toString().trim(), + () => undefined, + ) +} + +async function sh(sandbox: Sandbox, command: string, cwd?: string): Promise { + return (await sandbox.commands.run(command, { cwd, timeoutMs: 10 * 60_000 })).stdout +} + +/** Upload the working tree at `root` to `dir` in the sandbox. Returns the number of files. */ +export async function upload(sandbox: Sandbox, root: string, dir: string): Promise { + const listed = (await run('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root })) + .toString() + .split('\0') + // Tracked files deleted in the working tree are still listed; tar would fail on them. + const files = listed.filter((f) => f && existsSync(path.join(root, f))) + const tarball = await run('tar', ['-czf', '-', '--null', '-T', '-'], { cwd: root, input: files.join('\0') }) + + await sandbox.files.write('/tmp/pi-e2b-upload.tgz', new Uint8Array(tarball).buffer) + await sh(sandbox, `mkdir -p '${dir}' && tar -xzf /tmp/pi-e2b-upload.tgz -C '${dir}' && rm /tmp/pi-e2b-upload.tgz`) + await baseline(sandbox, dir, 'Your working tree when the session started') + return files.length +} + +/** An empty repository, for sessions started outside a git checkout. */ +export async function init(sandbox: Sandbox, dir: string): Promise { + await sh(sandbox, `mkdir -p '${dir}'`) + await baseline(sandbox, dir, 'Empty workspace') +} + +async function baseline(sandbox: Sandbox, dir: string, message: string): Promise { + await sh( + sandbox, + `git init -q -b main && git add -A && git ${AGENT} commit -q --allow-empty -m '${message}' && git update-ref ${BASE_REF} HEAD`, + dir, + ) +} + +export interface Changes { + /** Tree of the sandbox working directory now. */ + tree: string + /** `git diff --stat` against the baseline; empty when nothing changed. */ + stat: string + patch: Buffer +} + +/** Changes in the sandbox since the baseline. Uses a scratch index, so the agent's own index is untouched. */ +export async function changes(sandbox: Sandbox, dir: string): Promise { + const tree = ( + await sh(sandbox, 'export GIT_INDEX_FILE=$(mktemp -u); git add -A && git write-tree; rm -f "$GIT_INDEX_FILE"', dir) + ).trim() + const stat = (await sh(sandbox, `git diff --stat ${BASE_REF} ${tree}`, dir)).trim() + const patch = stat ? Buffer.from(await sh(sandbox, `git diff --binary ${BASE_REF} ${tree}`, dir)) : Buffer.alloc(0) + return { tree, stat, patch } +} + +/** + * Apply `changes` to the host checkout at `root`, then move the sandbox baseline to them. + * + * With `branch`, the changes are committed on that local branch instead (created + * if needed), so the commit holds the agent's changes only. That needs a clean + * working tree. Pushing and opening a pull request stay with the user's own git. + */ +export async function pull(sandbox: Sandbox, dir: string, root: string, c: Changes, branch?: string): Promise { + const git = (...args: string[]) => run('git', args, { cwd: root }) + const apply = (...args: string[]) => + run('git', ['apply', '--whitespace=nowarn', ...args, '-'], { cwd: root, input: c.patch }) + if (branch) { + if ((await git('status', '--porcelain')).length > 0) { + throw new Error('commit or stash your local changes first') + } + await git('switch', branch).catch(() => git('switch', '-c', branch)) + await apply('--index') + await git('commit', '-q', '-m', `Changes from Pi session in E2B\n\n${c.stat}`) + } else { + await apply() + } + await sh( + sandbox, + `git update-ref ${BASE_REF} $(git ${AGENT} commit-tree ${c.tree} -p ${BASE_REF} -m 'Pulled to host')`, + dir, + ) +} diff --git a/tools/oxlint/anti-slop/LICENSE b/tools/oxlint/anti-slop/LICENSE new file mode 100644 index 0000000..69239ea --- /dev/null +++ b/tools/oxlint/anti-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dillon Mulroy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/oxlint/anti-slop/VENDOR.md b/tools/oxlint/anti-slop/VENDOR.md new file mode 100644 index 0000000..6aa9f5c --- /dev/null +++ b/tools/oxlint/anti-slop/VENDOR.md @@ -0,0 +1,15 @@ +# anti-slop (vendored) + +Source: https://github.com/dmmulroy/anti-slop +Vendored commit: 95a56e5d24fb3d849673c2d51eb0908b8bd2d33b (upstream `src/`, unchanged) +License: MIT (see LICENSE) + +There is no npm package. Upstream tells you to copy `src/` into the repo, so this +folder is that copy. Do not edit these files by hand. To update, copy upstream `src/` +over this folder again and bump the commit above. + +`@oxlint/plugins` (root package.json) must be pinned to the exact same version as `oxlint`. +When you bump oxlint, bump `@oxlint/plugins` too. + +Rules are registered in `.oxlintrc.json` under `jsPlugins`. This folder is ignored by +oxlint and oxfmt so vendored code is never linted or reformatted. diff --git a/tools/oxlint/anti-slop/effect/index.ts b/tools/oxlint/anti-slop/effect/index.ts new file mode 100644 index 0000000..3724786 --- /dev/null +++ b/tools/oxlint/anti-slop/effect/index.ts @@ -0,0 +1,13 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts"; + +/** Opt-in Oxlint rules for Effect service and Layer architecture. */ +const antiSlopEffectPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop-effect" }, + rules: { + "no-service-constructor-imports": noServiceConstructorImportsRule, + }, +}); + +export default antiSlopEffectPlugin; diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.test.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.test.ts new file mode 100644 index 0000000..69578ec --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.test.ts @@ -0,0 +1,56 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noServiceConstructorImportsRule } from "./no-service-constructor-imports.ts"; + +new RuleTester().run( + "no-service-constructor-imports", + noServiceConstructorImportsRule, + { + valid: [ + { + filename: "src/issue-service.test.ts", + code: 'import { makeIssueService } from "./issue-service.ts";', + }, + { + filename: "src/issue-service.spec.tsx", + code: 'import { makeIssueService } from "../issue-service.ts";', + }, + { + filename: "src/runtime.ts", + code: 'import { makeExecutionMemo } from "alchemy/Runtime/ExecutionMemo";', + }, + { + filename: "src/runtime.ts", + code: 'import { issueServiceLayer } from "./issue-service.ts";\nWorkspaceName.make("name");', + }, + { + filename: "src/runtime.ts", + code: 'import { makeissueService } from "./issue-service.ts";', + }, + ], + invalid: [ + { + filename: "src/runtime.ts", + code: 'import { makeIssueService } from "./issue-service.ts";', + errors: [ + { + messageId: "serviceConstructorImport", + data: { name: "makeIssueService" }, + }, + ], + output: null, + }, + { + filename: "src/runtime.ts", + code: 'import { makeIssueService as createIssueService } from "../issue-service.ts";', + errors: [ + { + messageId: "serviceConstructorImport", + data: { name: "makeIssueService" }, + }, + ], + output: null, + }, + ], + }, +); diff --git a/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts new file mode 100644 index 0000000..55cefb7 --- /dev/null +++ b/tools/oxlint/anti-slop/effect/rules/no-service-constructor-imports.ts @@ -0,0 +1,52 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u; +const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u; + +function isProjectLocalImport(source: string): boolean { + return source.startsWith("./") || source.startsWith("../"); +} + +function getImportedName(specifier: ESTree.ImportSpecifier): string { + if (specifier.imported.type === "Identifier") return specifier.imported.name; + return specifier.imported.value; +} + +/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */ +export const noServiceConstructorImportsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow project-local make imports outside test and spec files.", + }, + messages: { + serviceConstructorImport: + 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.', + }, + }, + create(context) { + const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/")); + + return { + ImportDeclaration(node) { + if (isTestFile || !isProjectLocalImport(node.source.value)) return; + + for (const specifier of node.specifiers) { + if (specifier.type !== "ImportSpecifier") continue; + + const importedName = getImportedName(specifier); + if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue; + + context.report({ + node: specifier, + messageId: "serviceConstructorImport", + data: { name: importedName }, + }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 0000000..ef95c45 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,45 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +import { noArrayFilterMapRule } from "./rules/no-array-filter-map.ts"; +import { noReduceAccumulatorCopyRule } from "./rules/no-reduce-accumulator-copy.ts"; +import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts"; +import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; +import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; +import { noModuleMockingRule } from "./rules/no-module-mocking.ts"; +import { noObjectParametersRule } from "./rules/no-object-parameters.ts"; +import { noReflectApplyRule } from "./rules/no-reflect-apply.ts"; +import { noReflectGetRule } from "./rules/no-reflect-get.ts"; +import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts"; +import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts"; +import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; +import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; +import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; +import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; +import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; +import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts"; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: "anti-slop" }, + rules: { + "no-array-filter-map": noArrayFilterMapRule, + "no-reduce-accumulator-copy": noReduceAccumulatorCopyRule, + "no-chained-type-assertions": noChainedTypeAssertionsRule, + "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, + "no-known-value-widening": noKnownValueWideningRule, + "no-module-mocking": noModuleMockingRule, + "no-object-parameters": noObjectParametersRule, + "no-reflect-apply": noReflectApplyRule, + "no-reflect-get": noReflectGetRule, + "no-runtime-typeof": noRuntimeTypeofRule, + "no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule, + "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, + "no-unknown-parameters": noUnknownParametersRule, + "no-unknown-returns": noUnknownReturnsRule, + "no-unknown-type-aliases": noUnknownTypeAliasesRule, + "no-widen-then-assert": noWidenThenAssertRule, + "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-array-filter-map.test.ts b/tools/oxlint/anti-slop/rules/no-array-filter-map.test.ts new file mode 100644 index 0000000..d007921 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-array-filter-map.test.ts @@ -0,0 +1,41 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noArrayFilterMapRule } from "./no-array-filter-map.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "arrayFilterMap" }; + +tester.run("anti-slop/no-array-filter-map", noArrayFilterMapRule, { + valid: [ + "const users = []; users.values().filter(active).map(email).toArray();", + "const users = []; users.values().map(email).filter(Boolean).toArray();", + "Iterator.from(users).filter(active).map(email).toArray();", + "function collect(users: IteratorObject) { return users.filter(active).map(email).toArray(); }", + "const users = []; users.flatMap(user => user.active ? [user.email] : []);", + "const users = []; users.map(email); users.filter(active);", + "const users = []; users.map(email).map(normalize);", + "const users = []; users.filter(active).filter(verified);", + "const custom = { filter() { return this; }, map() {} }; custom.filter(active).map(email);", + "function collect(unknownReceiver) { return unknownReceiver.filter(active).map(email); }", + "const users = fetchUsers(); users.filter(active).map(email);", + "const users = []; function collect(users) { return users.filter(active).map(email); }", + "let users = []; users = iterator; users.filter(active).map(email);", + "const users = []; users[method](active).map(email);", + "const first = second; const second = first; first.filter(active).map(email);", + ], + invalid: [ + { code: "[].filter(active).map(email);", errors: [error] }, + { code: "[].map(user => user.active ? user.email : undefined).filter(email => email !== undefined);", errors: [error] }, + { code: "const users = []; users.map(email).filter(Boolean);", errors: [error] }, + { code: "const users = []; const alias = users; alias.filter(active).map(email);", errors: [error] }, + { code: "function collect(users: User[]) { return users.filter(active).map(email); }", errors: [error] }, + { code: "function collect(users: readonly User[]) { return users.map(email).filter(present); }", errors: [error] }, + { code: "function collect(users: ReadonlyArray) { return users.filter(active).map(email); }", errors: [error] }, + { code: "function collect(users: Array) { return users.filter(active).map(email); }", errors: [error] }, + { code: "const users = [] as const; users['filter'](active)['map'](email);", errors: [error] }, + { code: "const users = []; (users.filter(active)!).map(email);", errors: [error] }, + { code: "const users = []; users?.filter(active)?.map(email);", errors: [error] }, + { code: "const users = []; users.slice().filter(active).map(email);", errors: [error] }, + { code: "const users = []; users.filter(active).map(email).filter(Boolean);", errors: [error, error] }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-array-filter-map.ts b/tools/oxlint/anti-slop/rules/no-array-filter-map.ts new file mode 100644 index 0000000..dc001b3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-array-filter-map.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "../shared/array-method.ts"; + +/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */ +export const noArrayFilterMapRule = defineRule({ + meta: { + type: "suggestion", + docs: { description: "Disallow adjacent array filter/map passes in favor of lazy iterator helpers or a single transformation." }, + messages: { + arrayFilterMap: "Avoid consecutive array `{{first}}` and `{{second}}` passes. Prefer `.values().{{first}}(...).{{second}}(...).toArray()` where iterator helpers are supported, or a single `flatMap`/mutating reducer. Preserve callback ordering, indexes, and filtering semantics.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + const outer = arrayMethodTarget(node.callee); + if (outer === null || (outer.name !== "map" && outer.name !== "filter")) return; + const innerCall = unwrapArrayExpression(outer.object); + if (innerCall.type !== "CallExpression") return; + const inner = arrayMethodTarget(innerCall.callee); + if (inner === null || inner.name !== (outer.name === "map" ? "filter" : "map")) return; + if (!isKnownArrayExpression(context.sourceCode, inner.object)) return; + context.report({ node, messageId: "arrayFilterMap", data: { first: inner.name, second: outer.name } }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.test.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.test.ts new file mode 100644 index 0000000..c0e2b9a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.test.ts @@ -0,0 +1,32 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noChainedTypeAssertionsRule } from "./no-chained-type-assertions.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "chained" }; + +tester.run("anti-slop/no-chained-type-assertions", noChainedTypeAssertionsRule, { + valid: [ + "const value = input as User;", + "const value = (input as User);", + "const value = ({ id: 1 } as const) as const;", + ], + invalid: [ + { name: "as chain", code: "const value = input as unknown as User;", errors: [error] }, + { + name: "parenthesized chain", + code: "const value = (input as unknown) as User;", + errors: [error], + }, + { + name: "angle-bracket chain", + code: "const value = (input);", + errors: [error], + }, + { + name: "mixed const chain", + code: "const value = ({ id: 1 } as const) as User;", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000..0d11852 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts new file mode 100644 index 0000000..bccfba6 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.test.ts @@ -0,0 +1,32 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noConditionalEmptyObjectSpreadRule } from "./no-conditional-empty-object-spread.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "avoid" }; + +if (noConditionalEmptyObjectSpreadRule.meta?.fixable !== undefined) { + throw new Error("The rule must not offer an unsafe semantics-changing fix."); +} + +tester.run( + "anti-slop/no-conditional-empty-object-spread", + noConditionalEmptyObjectSpreadRule, + { + valid: [ + "const result = { value };", + "const result = { ...values };", + "const result = condition ? { value } : {};", + ], + invalid: [ + { + code: "const result = { ...(value !== undefined ? { value } : {}) };", + errors: [error], + }, + { + code: "const result = { ...(condition ? {} : { value }) };", + errors: [error], + }, + ], + }, +); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..ae7248d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts new file mode 100644 index 0000000..66dd635 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.test.ts @@ -0,0 +1,166 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noKnownValueWideningRule } from "./no-known-value-widening.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); + +const error = { messageId: "widening" }; + +const prelude = "type Command = () => void; const startCommand = () => {};"; + +tester.run("anti-slop/no-known-value-widening", noKnownValueWideningRule, { + valid: [ + `${prelude} const commands: Record = {};`, + `${prelude} type Index = Record; const commands: Index = {};`, + `${prelude} class Registry { commands: Record = {}; }`, + `${prelude} class Registry { accessor commands: Record = {}; }`, + `${prelude} let commands: Record; commands = {};`, + `${prelude} function create(): Record { return {}; }`, + `${prelude} const create = (): Record => ({});`, + `${prelude} const commands = {} as Record;`, + `${prelude} const commands = >{};`, + `${prelude} const commands = { start: startCommand };`, + `${prelude} const commands = { start: startCommand } as const;`, + `${prelude} const commands = { start: startCommand } satisfies Record;`, + `${prelude} type Commands = Record; const commands = { start: startCommand } as const satisfies Commands;`, + `${prelude} interface Commands { readonly start: Command } const commands: Commands = { start: startCommand };`, + `${prelude} type Commands = { readonly start: Command }; const commands: Commands = { start: startCommand };`, + `${prelude} type PermissionLevels = { readonly [Level in Permission]: number }; const levels: PermissionLevels = { admin: 1 };`, + `${prelude} function create() { return { start: startCommand }; }`, + `${prelude} interface Commands { readonly start: Command } function create(): Commands { return { start: startCommand }; }`, + `${prelude} declare function make(): Record; const commands: Record = make();`, + `${prelude} import { Commands } from './types'; const commands: Commands = { start: startCommand };`, + `${prelude} type Diet = 'vegan' | 'omnivore'; const labels: Record = { vegan: 'V', omnivore: 'O' };`, + `${prelude} type Diet = 'vegan' | 'omnivore'; type Labels = Record; const labels: Labels = { vegan: 'V', omnivore: 'O' };`, + `${prelude} type Diet = 'vegan' | 'omnivore'; const labels: Readonly> = { vegan: 'V', omnivore: 'O' };`, + `${prelude} const labels: Record<'a' | 'b', number> = { a: 1, b: 2 };`, + `${prelude} type Index = Record; const commands: Index<'start', Command> = { start: startCommand };`, + "function isString(value: unknown): value is string { return true; } declare const input: unknown; isString(input);", + "function isString(value: string | unknown): value is string { return true; } declare const input: string | unknown; isString(input);", + "function isString(value: unknown): value is string { return true; } declare function readInput(): unknown; isString(readInput());", + ], + invalid: [ + { code: "const value: unknown = {};", errors: [error] }, + { code: "const value: object = {};", errors: [error] }, + { code: "let value: unknown; value = {};", errors: [error] }, + { code: "function create(): unknown { return {}; }", errors: [error] }, + { + code: `${prelude} const commands: Record = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: { [key: string]: Command } = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: { [K in string]: Command } = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: { start: Command } = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands = { start: startCommand } as Record;`, + errors: [error], + }, + { + code: `${prelude} const commands = ({ start: startCommand } as Record) as object;`, + errors: 1, + }, + { + code: `${prelude} class Registry { commands: Record = { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} let commands: Record; commands = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} function create(): Record { return { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} function create(): { start: Command } { return { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} const source = { start: startCommand }; const commands: Record = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = Record; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = { [key: string]: Command }; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = { [key in string]: Command }; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} type Open = Readonly>; const source = { start: startCommand }; const commands: Open = source;`, + errors: [error], + }, + { + code: `${prelude} function outer() { type Open = Record; const commands: Open = { start: startCommand }; }`, + errors: [error], + }, + { + code: `${prelude} type Index = Record; const commands: Index = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Identity = T; const commands: Identity> = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Index = Record; type CommandsByName = Index; const commands: CommandsByName = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Index = Record; const commands: Index = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} type Key = string; const commands: Record = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: Record = { start: startCommand };`, + errors: [error], + }, + { + code: `${prelude} const commands: Record = { start: startCommand };`, + errors: [error], + }, + { code: "const value: unknown = 1;", errors: [error] }, + { code: "const value: object = [];", errors: [error] }, + { + code: "function isString(value: unknown): value is string { return true; } isString('known');", + errors: [error], + }, + { + code: "function isString(value: unknown): value is string { return true; } const known = 'known'; isString(known);", + errors: [error], + }, + { + code: "function isString(value: string | unknown): value is string { return true; } isString('known');", + errors: [error], + }, + { + code: "function isString(value: unknown): value is string { return true; } function check(known: string): boolean { return isString(known); }", + errors: [error], + }, + { + code: "const isString = (value: unknown): value is string => true; const known: string = getValue(); isString(known);", + errors: [error], + }, + { + code: "type User = { readonly id: string }; function isUser(value: unknown): value is User { return true; } function parse(): User { return { id: 'known' }; } const user = parse(); isUser(user);", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..7defff3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,427 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionaryValue, + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.ts"; +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === "VariableDeclaration" && + declarator.parent.kind === "const" && + variable.references.every((reference) => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set(), +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== "Identifier") return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function isFunctionExpression(node: ESTree.Node): node is FunctionExpression { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "TSDeclareFunction" || + node.type === "TSEmptyBodyFunctionExpression" + ); +} + +function localFunctionForCall( + sourceCode: SourceCode, + callee: ESTree.Expression, +): FunctionExpression | null { + const unwrapped = unwrapExpression(callee); + if (isFunctionExpression(unwrapped)) return unwrapped; + if (unwrapped.type !== "Identifier") return null; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) { + return definition.node; + } + if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") { + return null; + } + const initializer = definition.node.init; + if (initializer === null) return null; + const unwrappedInitializer = unwrapExpression(initializer); + return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null; +} + +function variableTypeAnnotation( + sourceCode: SourceCode, + variable: Variable, +): ESTree.TSTypeAnnotation | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + if (definition === undefined) return null; + if ( + definition.type === "Variable" && + definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" + ) { + return definition.node.id.typeAnnotation ?? null; + } + if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) { + return null; + } + const parameter = definition.node.params.find( + (candidate) => + functionParameterBindingName(candidate, sourceCode) === variable.name, + ); + return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null); +} + +function hasInformativeType( + type: ESTree.TSType, + environment: TypeEnvironment, +): boolean { + return classifyUnsafeDictionaryValue(type, environment) === null; +} + +function hasKnownCallArgumentEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + environment: TypeEnvironment, + visitedVariables = new Set(), +): boolean { + if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") { + return hasInformativeType(expression.typeAnnotation, environment); + } + if (expression.type === "TSSatisfiesExpression") { + return hasKnownCallArgumentEvidence( + sourceCode, + expression.expression, + environment, + visitedVariables, + ); + } + if (expression.type === "CallExpression") { + const owner = localFunctionForCall(sourceCode, expression.callee); + const returnType = owner?.returnType?.typeAnnotation; + return returnType !== undefined && hasInformativeType(returnType, environment); + } + if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression); + const variable = resolveVariable(sourceCode, expression); + if (variable === null || visitedVariables.has(variable)) return false; + const annotation = variableTypeAnnotation(sourceCode, variable); + if (annotation !== null) { + return hasInformativeType(annotation.typeAnnotation, environment); + } + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownCallArgumentEvidence( + sourceCode, + declarator.init, + environment, + visitedVariables, + ); +} + +function typePredicateSubjectIndex( + sourceCode: SourceCode, + owner: FunctionExpression, +): number | null { + const predicate = owner.returnType?.typeAnnotation; + if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") { + return null; + } + const predicateParameterName = predicate.parameterName.name; + const index = owner.params.findIndex( + (parameter) => + functionParameterBindingName(parameter, sourceCode) === predicateParameterName, + ); + return index === -1 ? null : index; +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment, +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if ( + current.type === "ArrowFunctionExpression" || + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name; + if (key.type === "Literal") return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return "anonymous function"; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") + return parent.id.name; + if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key); + return "anonymous function"; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === "open dictionary" || destination.kind === "generic container"; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion"; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.", + }, + messages: { + widening: + "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string, + ) => { + if (destination === null) return; + if ( + isDictionaryAccumulatorTarget(destination) && + isEmptyObjectExpression(expression) + ) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: "widening", + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== "Identifier") return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\``, + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\``, + ); + }, + AssignmentExpression(node) { + if (node.operator !== "=" || node.left.type !== "Identifier") return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== "Identifier") return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\``, + ); + }, + CallExpression(node) { + if (environment === null) return; + const owner = localFunctionForCall(context.sourceCode, node.callee); + if (owner === null) return; + const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner); + if (parameterIndex === null) return; + const parameter = owner.params[parameterIndex]; + const argument = node.arguments[parameterIndex]; + if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") { + return; + } + const parameterAnnotation = functionParameterTypeAnnotation(parameter); + if ( + parameterAnnotation === null || + parameterAnnotation === undefined || + !containsUnknownType(parameterAnnotation.typeAnnotation) + ) { + return; + } + if ( + !hasKnownCallArgumentEvidence( + context.sourceCode, + argument, + environment, + ) + ) { + return; + } + context.report({ + node: argument, + messageId: "widening", + data: { + subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``, + target: "unknown", + }, + }); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\``, + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === "BlockStatement") return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\``, + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + "assertion", + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.test.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.test.ts new file mode 100644 index 0000000..b4c22cc --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.test.ts @@ -0,0 +1,28 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noModuleMockingRule } from "./no-module-mocking.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "moduleMock" }; + +tester.run("anti-slop/no-module-mocking", noModuleMockingRule, { + valid: [ + "const store = new InMemoryUserStore();", + "vi.spyOn(store, 'save');", + "const vi = { mock() {} }; vi.mock();", + "function test(jest: { mock(): void }) { jest.mock(); }", + "import { vi as localVi } from './helpers'; localVi.mock('./module');", + ], + invalid: [ + { code: "vi.mock('./user-store');", errors: [error] }, + { code: "jest.mock('./user-store');", errors: [error] }, + { code: "vi['doMock']('./user-store');", errors: [error] }, + { code: "jest.unstable_mockModule('./user-store');", errors: [error] }, + { code: "import { vi } from 'vitest'; vi.mock('./user-store');", errors: [error] }, + { code: "import { vi as testApi } from 'vitest'; testApi.mock('./user-store');", errors: [error] }, + { + code: "import { jest } from '@jest/globals'; jest.mock('./user-store');", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 0000000..d6fb5b4 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.test.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.test.ts new file mode 100644 index 0000000..3c5f08d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.test.ts @@ -0,0 +1,63 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noObjectParametersRule } from "./no-object-parameters.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "objectParameter" }; + +tester.run("anti-slop/no-object-parameters", noObjectParametersRule, { + valid: [ + "type Alias = object;", + "function f(value: Alias) {}", + "interface Owner { readonly id: string } function f(value: Owner) {}", + "function f(value: Value) {}", + "function f(value: Value) {}", + "function f(value: Value) {}", + "type Owner = { readonly id: string }; function f(value: Value) {}", + "type Alias = object; function consume(value: Alias) {}", + "type Alias = object; type Consumer = (value: Alias) => void;", + "type Alias = object; interface Consumer { consume(value: Alias): void }", + "type Key = object; type Mapped = { [Key in keyof Input]: (value: Key) => void };", + "type Item = object; type Unpacked = Input extends Promise ? (value: Item) => void : never;", + "type Payload = object; function outer() { type Payload = { readonly id: string }; function consume(value: Payload) {} }", + "type Identity = T; function consume(value: Identity) {}", + "function one() { type Payload = object; } function two() { function consume(value: Payload) {} }", + ], + invalid: [ + { code: "function f(value: object) {}", errors: [error] }, + { code: "type Alias = object; function f(value: Alias) {}", errors: [error] }, + { code: "type Alias = (object); function f(value: Alias) {}", errors: [error] }, + { + code: "type Item = object; type Fallback = Input extends infer Item ? string : (value: Item) => void;", + errors: [error], + }, + { + code: "function consume(value: object = {}): void {}", + errors: [{ ...error, data: { parameter: "value" } }], + }, + { + code: "function consume({ value }: object = {}): void {}", + errors: [{ ...error, data: { parameter: "{ value }" } }], + }, + { + code: "type Bag = object; function consume({ value }: Bag): void {}", + errors: [{ ...error, data: { parameter: "{ value }" } }], + }, + { + code: "function outer() { type Payload = object; function consume(value: Payload) {} }", + errors: [error], + }, + { + code: "function outer() { function consume(value: Payload) {} type Payload = object; }", + errors: [error], + }, + { + code: "type Identity = T; function consume(value: Identity) {}", + errors: [error], + }, + { + code: "type Identity = T; type Wrapped = Identity; function consume(value: Wrapped) {}", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 0000000..6589ebf --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToObject = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSObjectKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + return ( + resolved.type === "TSUnionType" && resolved.types.some(matches) + ); + }); + + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = functionParameterTypeAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: functionParameterBindingName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.test.ts b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.test.ts new file mode 100644 index 0000000..48f8944 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.test.ts @@ -0,0 +1,52 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noReduceAccumulatorCopyRule } from "./no-reduce-accumulator-copy.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "accumulatorCopy" }; + +tester.run("anti-slop/no-reduce-accumulator-copy", noReduceAccumulatorCopyRule, { + valid: [ + "items.reduce((acc, item) => { acc.push(item); return acc; }, []);", + "items.reduce((acc, item) => Object.assign(acc, item), {});", + "items.reduce((acc, item) => Object.assign(acc, acc, item), {});", + "items.reduce((acc, item) => { acc[item.id] = { ...item }; return acc; }, {});", + "items.reduce((acc, item) => { acc.push(Object.assign({}, item)); return acc; }, []);", + "items.reduce((acc, item) => { acc.push(item.slice()); return acc; }, []);", + "items.reduce((acc, item) => acc.concat(item), '');", + "items.reduce((acc, item) => acc.concat(item), customCollection);", + "function copy(acc) { return Object.assign({}, acc); }", + "items.map((acc, item) => Object.assign({}, acc));", + "items.reduce((acc, item) => { function copy(acc) { return Object.assign({}, acc); } return acc; }, {});", + "items.reduce((acc, item) => { const snapshot = () => Object.assign({}, acc); return acc; }, {});", + "items.reduce((acc, item) => { { const acc = {}; Object.assign({}, acc); } return acc; }, {});", + "const Object = custom; items.reduce((acc, item) => Object.assign({}, acc), {});", + "function run(Object) { return items.reduce((acc, item) => Object.assign({}, acc), {}); }", + "const Array = custom; items.reduce((acc, item) => Array.from(acc), []);", + "items.reduce((acc, item) => { let alias = acc; alias = item; return Object.assign({}, alias); }, {});", + "items.reduce((acc, item) => [...acc, item], []);", // Owned by the native rule. + "items.reduce((acc, item) => ({ ...acc, [item.id]: item }), {});", + ], + invalid: [ + { code: "items.reduce((acc, item) => Object.assign({}, acc, { [item.id]: item }), {});", errors: [error] }, + { code: "items.reduceRight((acc, item) => Object.assign({}, acc, item), {});", errors: [error] }, + { code: "items.reduce((acc, item, index, array) => Object.assign({}, acc, item), {});", errors: [error] }, + { code: "items.reduce(acc => Object.assign({}, acc), {});", errors: [error] }, + { code: "items.reduce(function (acc, item) { return Object.assign({}, item, acc); }, {});", errors: [error] }, + { code: "items['reduce'](((acc, item) => Object['assign']({}, acc, item)), {});", errors: [error] }, + { code: "items.reduce((acc = {}, item) => Object.assign({}, acc, item), {});", errors: [error] }, + { code: "items.reduce((acc, item) => { const alias = acc; return Object.assign({}, alias, item); }, {});", errors: [error] }, + { code: "items.reduce((acc, item) => Object.assign({}, acc as State, item), {});", errors: [error] }, + { code: "items.reduce((acc, item) => { const next = Object.assign({}, acc); next[item.id] = item; return next; }, {});", errors: [error] }, + { code: "items.reduce((acc, item) => acc.concat([item]), []);", errors: [error] }, + { code: "items.reduceRight((acc, item, index) => acc['concat']([item]), [] as Item[]);", errors: [error] }, + { code: "items.reduce((acc, item) => { const next = acc.slice(); next.push(item); return next; }, []);", errors: [error] }, + { code: "items.reduce((acc, item) => { const alias = acc; return alias.concat(item); }, []);", errors: [error] }, + { code: "const initial = []; items.reduce((acc, item) => acc.concat(item), initial);", errors: [error] }, + { code: "items.reduce((acc, item) => { const next = Array.from(acc); next.push(item); return next; }, []);", errors: [error] }, + { code: "items.reduce((acc, item) => acc.toSpliced(acc.length, 0, item), []);", errors: [error] }, + { code: "items.reduce((acc, item) => acc.toSorted(), []);", errors: [error] }, + { code: "items.reduce((acc, item) => acc.toReversed(), []);", errors: [error] }, + { code: "items.reduce((acc, item) => acc.with(0, item), []);", errors: [error] }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts new file mode 100644 index 0000000..47d8e2c --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reduce-accumulator-copy.ts @@ -0,0 +1,109 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, SourceCode, Variable } from "@oxlint/plugins"; + +import { + arrayMethodTarget, + isKnownArrayExpression, + resolveArrayBinding, + unwrapArrayExpression, +} from "../shared/array-method.ts"; + +function enclosingReducer(node: ESTree.Node) { + let parent = node.parent; + while (parent !== null) { + if (parent.type === "FunctionDeclaration") return null; + if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionExpression") { + const callback = parent; + let owner: ESTree.Node | null = callback.parent; + while (owner !== null && unwrapArrayExpression(owner) === callback) owner = owner.parent; + if (owner?.type !== "CallExpression") return null; + const method = arrayMethodTarget(owner.callee); + const firstArgument = owner.arguments[0]; + if ( + method === null || (method.name !== "reduce" && method.name !== "reduceRight") || + owner.arguments.length > 2 || firstArgument === undefined || + unwrapArrayExpression(firstArgument) !== callback + ) return null; + const firstParameter = callback.params[0]; + const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter; + if (accumulator?.type !== "Identifier") return null; + return { callback, accumulator, initialValue: owner.arguments[1] }; + } + parent = parent.parent; + } + return null; +} + +function referencesAccumulator( + sourceCode: SourceCode, + node: ESTree.Node, + accumulator: Variable, + visited = new Set(), +): boolean { + const variable = resolveArrayBinding(sourceCode, node); + if (variable === null || visited.has(variable)) return false; + if (variable === accumulator) return true; + visited.add(variable); + if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false; + for (const definition of variable.defs) { + if ( + definition.type === "Variable" && definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" && definition.node.init !== null && + definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const" + ) { + return referencesAccumulator(sourceCode, definition.node.init, accumulator, visited); + } + } + return false; +} + +function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: string): boolean { + node = unwrapArrayExpression(node); + if (node.type !== "Identifier" || node.name !== name) return false; + const variable = resolveArrayBinding(sourceCode, node); + return variable === null || variable.defs.length === 0; +} + +/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */ +export const noReduceAccumulatorCopyRule = defineRule({ + meta: { + type: "problem", + docs: { description: "Disallow copying growing reducer accumulators with Object.assign, Array.from, or array copy methods." }, + messages: { + accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + const method = arrayMethodTarget(node.callee); + if (method === null) return; + const reducer = enclosingReducer(node); + if (reducer === null) return; + const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable => + variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start), + ); + if (accumulator === undefined) return; + const isAccumulator = (expression: ESTree.Node) => + referencesAccumulator(context.sourceCode, expression, accumulator); + let copiesAccumulator = false; + if (method.name === "assign" && isGlobalCopyOwner(context.sourceCode, method.object, "Object")) { + const target = node.arguments[0]; + copiesAccumulator = ( + target !== undefined && unwrapArrayExpression(target).type === "ObjectExpression" && + node.arguments.slice(1).some(isAccumulator) + ); + } else if (method.name === "from" && isGlobalCopyOwner(context.sourceCode, method.object, "Array")) { + const source = node.arguments[0]; + copiesAccumulator = source !== undefined && isAccumulator(source); + } else if (["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"].includes(method.name)) { + const initialValue = reducer.initialValue; + const arrayAccumulator = initialValue !== undefined && + isKnownArrayExpression(context.sourceCode, initialValue); + copiesAccumulator = arrayAccumulator && isAccumulator(method.object); + } + if (copiesAccumulator) context.report({ node, messageId: "accumulatorCopy" }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts new file mode 100644 index 0000000..ee24df8 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.test.ts @@ -0,0 +1,19 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noReflectApplyRule } from "./no-reflect-apply.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "reflectApply" }; + +tester.run("anti-slop/no-reflect-apply", noReflectApplyRule, { + valid: [ + "const value = operation.apply(owner, args);", + "Reflect.get(owner, key);", + "const Reflect = { apply() { return 1; } }; Reflect.apply();", + "function invoke(Reflect: { apply(): number }) { return Reflect.apply(); }", + ], + invalid: [ + { code: "const value = Reflect.apply(operation, owner, args);", errors: [error] }, + { code: "const value = Reflect['apply'](operation, owner, args);", errors: [error] }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 0000000..2cc3045 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.test.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.test.ts new file mode 100644 index 0000000..c4d9155 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.test.ts @@ -0,0 +1,20 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noReflectGetRule } from "./no-reflect-get.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "reflectGet" }; + +tester.run("anti-slop/no-reflect-get", noReflectGetRule, { + valid: [ + "const value = owner.property;", + "const value = owner[key];", + "Reflect.set(owner, key, value);", + "const Reflect = { get() { return 1; } }; Reflect.get();", + "function read(Reflect: { get(): number }) { return Reflect.get(); }", + ], + invalid: [ + { name: "static access", code: "const value = Reflect.get(owner, key);", errors: [error] }, + { name: "computed access", code: "const value = Reflect['get'](owner, key);", errors: [error] }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000..cf630ec --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts new file mode 100644 index 0000000..abeeaed --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.test.ts @@ -0,0 +1,47 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noRuntimeTypeofRule } from "./no-runtime-typeof.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "runtimeTypeof" }; +const allowInTypeGuards = [{ allowInTypeGuards: true }]; + +tester.run("anti-slop/no-runtime-typeof", noRuntimeTypeofRule, { + valid: [ + 'const isServer = typeof document === "undefined";', + 'const hasStorage = typeof localStorage !== "undefined";', + 'if (typeof globalThis.crypto === "undefined") throw new Error("no crypto");', + 'const missing = "undefined" === typeof process;', + "const value = input;", + { + code: 'function isString(value: unknown): value is string { return typeof value === "string"; }', + options: allowInTypeGuards, + }, + { + code: 'const isString = (value: unknown): value is string => typeof value === "string";', + options: allowInTypeGuards, + }, + { + code: 'function assertString(value: unknown): asserts value is string { if (typeof value !== "string") throw new Error(); }', + options: allowInTypeGuards, + }, + ], + invalid: [ + { code: 'if (typeof input === "string") use(input);', errors: [error] }, + { code: "if (typeof input === undefined) use(input);", errors: [error] }, + { + code: 'function isString(value: unknown): value is string { return typeof value === "string"; }', + errors: [error], + }, + { + code: 'function parse(value: unknown): string { if (typeof value !== "string") throw new Error(); return value; }', + options: allowInTypeGuards, + errors: [error], + }, + { + code: 'function isString(value: unknown): value is string { const check = () => typeof value === "string"; return check(); }', + options: allowInTypeGuards, + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..43259eb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === "ArrowFunctionExpression" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === "TSTypePredicate"; + } + current = current.parent; + } + return false; +} + +/** Return whether typeof safely probes for the existence of a possibly absent binding. */ +function isExistenceProbe(node: ESTree.UnaryExpression): boolean { + const parent = node.parent; + if (parent.type !== "BinaryExpression") return false; + if (!["===", "!==", "==", "!="].includes(parent.operator)) return false; + const other = parent.left === node ? parent.right : parent.left; + return other.type === "Literal" && other.value === "undefined"; +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.", + }, + messages: { + runtimeTypeof: + "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.", + }, + schema: [ + { + type: "object", + properties: { + allowInTypeGuards: { type: "boolean" }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === "object" && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if ( + node.operator === "typeof" && + !isExistenceProbe(node) && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.test.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.test.ts new file mode 100644 index 0000000..4d34612 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.test.ts @@ -0,0 +1,25 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noForbiddenTermInSymbolNamesRule } from "./no-shape-in-symbol-names.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "forbiddenSymbolName" }; + +tester.run("anti-slop/no-shape-in-symbol-names", noForbiddenTermInSymbolNamesRule, { + valid: [ + "declare const schema: ExternalSchema; const field = schema.shape.id;", + "declare const outer: External; const value = outer.inner.shape;", + "declare const schema: ExternalSchema; schema.shape.id.parse('x');", + "const owner = { id: 1 }; const value = owner.id;", + ], + invalid: [ + { code: "const shape = 1;", errors: [error] }, + { code: "function shapeOf() {}", errors: [error] }, + { code: "type PayloadShape = { id: string };", errors: [error] }, + { code: "type Payload = { shape: string };", errors: [error] }, + { + code: "declare const owner: External; const shape = 'field'; const value = owner[shape];", + errors: 2, + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000..436d2a2 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,46 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +const FORBIDDEN_SYMBOL_NAME = "shape"; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Return whether an identifier names a statically accessed member owned by another value. */ +function isBorrowedMemberName(node: ESTree.Node): boolean { + const parent = node.parent; + if (parent === null || parent.type !== "MemberExpression") return false; + return parent.property === node && parent.computed === false; +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return; + context.report({ + node, + messageId: "forbiddenSymbolName", + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.test.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.test.ts new file mode 100644 index 0000000..75aca19 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.test.ts @@ -0,0 +1,36 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnknownParametersRule } from "./no-unknown-parameters.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "unknownParameter" }; + +tester.run("anti-slop/no-unknown-parameters", noUnknownParametersRule, { + valid: [ + "function enrich(cause: unknown): void {}", + "function enrich(cause: Error | unknown): void {}", + "function isString(value: unknown): value is string { return true; }", + "const isString = (value: unknown): value is string => true;", + "function assertString(value: unknown): asserts value is string {}", + "type Guard = (value: unknown) => value is string;", + "declare function isString(value: unknown): value is string;", + "type Guards = { isString(value: unknown): value is string };", + "function parse(value: string | number): void {}", + ], + invalid: [ + { code: "function parse(value: unknown): void {}", errors: [error] }, + { code: "function parse(value: string | unknown): void {}", errors: [error] }, + { + code: "function parse(value: string | (number | unknown)): void {}", + errors: [error], + }, + { + code: "function isString(value: unknown, context: unknown): value is string { return true; }", + errors: [{ ...error, data: { parameter: "context" } }], + }, + { + code: "export function parse({ value }: unknown = {}): void {}", + errors: [{ ...error, data: { parameter: "{ value }" } }], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 0000000..b4a1545 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,69 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +import { + containsUnknownType, + functionParameterBindingName, + functionParameterTypeAnnotation, +} from "../shared/function-parameters.ts"; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function isTypePredicateSubject(owner: ParameterOwner, parameterName: string): boolean { + const predicate = owner.returnType?.typeAnnotation; + return ( + predicate?.type === "TSTypePredicate" && + predicate.parameterName.type === "Identifier" && + predicate.parameterName.name === parameterName + ); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow explicitly unknown function parameters except `cause` and type-predicate subjects; decode unknown input at its I/O boundary instead.", + }, + messages: { + unknownParameter: + "Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.", + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = functionParameterTypeAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!containsUnknownType(annotation.typeAnnotation)) continue; + const name = functionParameterBindingName(parameter, context.sourceCode); + if (name === "cause" || isTypePredicateSubject(node, name)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "unknownParameter", + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts new file mode 100644 index 0000000..f0c1e79 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.test.ts @@ -0,0 +1,47 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnknownReturnsRule } from "./no-unknown-returns.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "unknownReturn" }; + +tester.run("anti-slop/no-unknown-returns", noUnknownReturnsRule, { + valid: [ + "type ImportedValue = unknown;", + "function parse(): ImportedValue { return input; }", + "function parse(): User { return user; }", + "function infer() { return input; }", + "function generic(): Value { return value; }", + "type Value = unknown; function generic(): Value { return value; }", + "type Key = unknown; type Mapped = { [Key in keyof Input]: () => Key };", + "type Item = unknown; type Unpacked = Input extends Promise ? () => Item : never;", + "function cause(): { cause: unknown } { return { cause: input }; }", + "type Result = { value: unknown }; function load(): Result { return result; }", + "function load(): Promise { return promise; }", + "type Identity = T; function load(): Identity { return user; }", + "type Value = unknown; function outer() { type Value = User; function load(): Value { return user; } }", + ], + invalid: [ + { code: "function load(): unknown { return input; }", errors: [error] }, + { code: "const load = (): unknown => input;", errors: [error] }, + { code: "type Loader = () => unknown;", errors: [error] }, + { code: "interface Loader { load(): unknown }", errors: [error] }, + { code: "declare function load(): unknown;", errors: [error] }, + { code: "function load(): string | unknown { return input; }", errors: [error] }, + { code: "function load(): Promise { return promise; }", errors: [error] }, + { code: "type UnknownValue = unknown; function load(): UnknownValue { return input; }", errors: [error] }, + { code: "type Item = unknown; type Fallback = Input extends infer Item ? string : () => Item;", errors: [error] }, + { + code: "function outer() { type Result = unknown; function load(): Result { return input; } }", + errors: [error], + }, + { + code: "type Identity = T; function load(): Identity { return input; }", + errors: [error], + }, + { + code: "type Identity = T; type Wrapped = Promise>; function load(): Wrapped { return promise; }", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 0000000..e1f43f8 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,82 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow functions whose explicit return contract is unknown or Promise.", + }, + messages: { + unknownReturn: + "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToUnknown = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSUnknownKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + if (resolved.type === "TSUnionType") return resolved.types.some(matches); + if ( + resolved.type !== "TSTypeReference" || + resolved.typeName.type !== "Identifier" || + (resolved.typeName.name !== "Promise" && + resolved.typeName.name !== "PromiseLike") + ) { + return false; + } + const value = resolved.typeArguments?.params[0]; + return value !== undefined && matches(value); + }); + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if (!resolvesToUnknown(annotation.typeAnnotation)) return; + context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); + }; + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts new file mode 100644 index 0000000..32c1151 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.test.ts @@ -0,0 +1,37 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnknownTypeAliasesRule } from "./no-unknown-type-aliases.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "unknownAlias" }; + +tester.run("anti-slop/no-unknown-type-aliases", noUnknownTypeAliasesRule, { + valid: [ + "type User = { readonly id: string };", + "type Alias = string; type UserId = Alias;", + "type Payload = string | number;", + "type Box = { readonly value: T }; type Payload = Box;", + ], + invalid: [ + { code: "type Alias = unknown;", errors: [error] }, + { code: "type Current = unknown;", errors: [error] }, + { code: "type UnknownValue = unknown; type Alias = UnknownValue;", errors: [error, error] }, + { code: "type Payload = string | unknown;", errors: [error] }, + { + code: "type Payload = string | unknown; type NestedPayload = number | Payload;", + errors: [error, error], + }, + { + code: "type Identity = T; type Payload = Identity;", + errors: [error], + }, + { + code: "type Identity = T; type Wrapped = Identity; type Payload = Wrapped;", + errors: [error], + }, + { + code: "function outer() { type Payload = unknown; }", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000..af5f08a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,54 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + resolvedTypeMatches, + type TypeAliasEnvironment, +} from "../shared/type-alias-resolution.ts"; + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.", + }, + messages: { + unknownAlias: + "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.", + }, + }, + createOnce(context) { + let environment: TypeAliasEnvironment | null = null; + + const resolvesToUnknown = (type: ESTree.TSType): boolean => + environment !== null && + resolvedTypeMatches(type, environment, (resolved, matches) => { + if (resolved.type === "TSUnknownKeyword") return true; + if (resolved.type === "TSParenthesizedType") { + return matches(resolved.typeAnnotation); + } + return resolved.type === "TSUnionType" && resolved.types.some(matches); + }); + + return { + Program(node) { + environment = createTypeAliasEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeAliasDeclaration(node) { + if (!resolvesToUnknown(node.typeAnnotation)) return; + context.report({ + node: node.id, + messageId: "unknownAlias", + data: { alias: node.id.name }, + }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts new file mode 100644 index 0000000..c45fdac --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.test.ts @@ -0,0 +1,115 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnsafeDictionaryTypeRule } from "./no-unsafe-dictionary-type.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); + +const error = { messageId: "unsafeDictionary" }; + +tester.run("anti-slop/no-unsafe-dictionary-type", noUnsafeDictionaryTypeRule, { + valid: [ + "type Commands = Record;", + "type Metadata = Record;", + "type PermissionLevels = Record;", + "type Indexed = { [key: string]: Command };", + "type CompatibleIndexes = { [index: number]: Command; [key: string]: Command | OtherCommand };", + "type Exhaustive = { [K in Permission]: number };", + "type Allowed = Record;", + "type AlsoAllowed = Record>;", + "type Index = Record; type EntityIndex = Record;", + "type Safe = Index; type Index = Record;", + "type A = Map; type B = ReadonlyMap; type C = WeakMap;", + "import { Record } from './local'; type A = Record;", + "type Record = { key: K; value: V }; type A = Record;", + "type Readonly = { value: T }; type A = Record>;", + "type NonNullable = { value: T }; type A = Record>;", + "type Value = T; type Index> = Record; type A = Index;", + "interface Owner { readonly id: string } type A = Record;", + "interface Owner { readonly id: string } interface Child extends Owner {} type A = Record;", + "interface Owner { readonly id: string } interface Child extends Owner { readonly __brand?: never } type A = Record;", + "interface Escape {} interface Escape { readonly id: string } type A = Record;", + "interface Escape { readonly id: string } interface Escape {} type A = Record;", + "interface Owner { readonly id: string } type A = Record;", + "type Wrap = { readonly wrapped: T }; type Inner = { readonly value: T } & Wrap; type Outer = Record>; declare function f(): Outer;", + "type WithSchema> = (schema: T) => void;", + "function run>(input: T): T { return input; }", + "declare class Store> { read(): T }", + "type Deep>> = T;", + ], + invalid: [ + { code: "type A = Record;", errors: [error] }, + { code: "type A = { [key: string]: any };", errors: [error] }, + { code: "type A = { [index: number]: Command; [key: string]: unknown | Command };", errors: 1 }, + { code: "type A = { [K in PropertyKey]: object };", errors: [error] }, + { code: "type A = { [K in PropertyKey]: NonNullable };", errors: [error] }, + { code: "type A = { [key: string]: NonNullable };", errors: [error] }, + { code: "type A = Record;", errors: [error] }, + { code: "interface Escape {} type A = Record;", errors: [error] }, + { + code: "interface Escape { readonly __brand?: never } type A = Record;", + errors: [error], + }, + { + code: "type Escape = { readonly __brand?: never }; type A = Record;", + errors: [error], + }, + { code: "type A = Record;", errors: [error] }, + { code: "type A = Record;", errors: [error] }, + { code: "interface Escape {} type A = Record;", errors: [error] }, + { code: "type A = Record;", errors: [error] }, + { + code: "interface Owner { readonly id: string } type A = Record;", + errors: [error], + }, + { code: "type Escape = unknown; type A = Record;", errors: [error] }, + { code: "type Dict = Record;", errors: [error] }, + { code: "type A = Readonly)>>>;", errors: [error] }, + { code: "type A = { readonly [key: string]: unknown };", errors: [error] }, + { code: "type A = { readonly [K in string]: unknown };", errors: [error] }, + { code: "type Source = Record; type A = Pick;", errors: 2 }, + { code: "type Source = Record; type A = Omit;", errors: 2 }, + { code: "type Index = Record; type A = Index;", errors: 1 }, + { code: "interface A { [key: string]: unknown }", errors: [error] }, + { code: "type A = Readonly>;", errors: 1 }, + { code: "type A = Record>;", errors: [error] }, + { code: "type A = Record>;", errors: [error] }, + { code: "type A = Record>;", errors: [error] }, + { code: "type Escape = Readonly; type A = Record;", errors: 1 }, + { + code: "type Wrapped = Readonly; type A = Record>;", + errors: 1, + }, + { code: "type A = Record>;", errors: [error] }, + { code: "type Escape = NonNullable; type A = Record;", errors: 1 }, + { + code: "type Unsafe = Record; const x: Unsafe = {}; const y: Unsafe = {};", + errors: 1, + }, + { + code: "type Unsafe = Record; type AlsoUnsafe = Unsafe; const x: Unsafe = {};", + errors: 2, + }, + { code: "type Index = Record; type A = Index;", errors: 1 }, + { + code: "type Index = Record; type A = Index;", + errors: 1, + }, + { code: "type Index = Record; type A = Index;", errors: 1 }, + { + code: "type Value = T; type Index> = Record; type A = Index;", + errors: 1, + }, + { + code: "type Marker = { readonly __brand?: never }; type Index> = Record; type A = Index;", + errors: 1, + }, + { + code: "function outer() { type Identity = T; type A = Record>; }", + errors: 1, + }, + { + code: "function local() { type Record = { key: K; value: V }; type A = Record; } function global() { type A = Record; }", + errors: 1, + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000..8cb615a --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,154 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.ts"; +import { visibleTypeAlias } from "../shared/type-alias-resolution.ts"; + +import type { ESTree } from "@oxlint/plugins"; + +const typeNodeKinds: ReadonlySet = new Set([ + "JSDocNonNullableType", + "JSDocNullableType", + "JSDocUnknownType", + "TSAnyKeyword", + "TSArrayType", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSConditionalType", + "TSConstructorType", + "TSFunctionType", + "TSImportType", + "TSIndexedAccessType", + "TSInferType", + "TSIntersectionType", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSMappedType", + "TSNamedTupleMember", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSParenthesizedType", + "TSStringKeyword", + "TSSymbolKeyword", + "TSTemplateLiteralType", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeOperator", + "TSTypePredicate", + "TSTypeQuery", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnionType", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (current.type === "TSTypeAliasDeclaration") return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return ( + name !== null && + visibleTypeAlias(name, node, environment.typeAliases) !== null && + !isInsideTypeAliasDeclaration(node) + ); +} + +function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean { + let child: ESTree.Node = node; + let parent: ESTree.Node | null = child.parent; + while (parent !== null && parent.type !== "Program") { + if (parent.type === "TSTypeParameter" && parent.constraint === child) return true; + child = parent; + parent = child.parent; + } + return false; +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isInsideTypeParameterConstraint(node)) return false; + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== "Program") { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.", + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: "unsafeDictionary", data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment( + node, + context.sourceCode.visitorKeys, + ); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === "TSTypeLiteral" + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment, + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts new file mode 100644 index 0000000..0686110 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.test.ts @@ -0,0 +1,19 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { noWidenThenAssertRule } from "./no-widen-then-assert.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "widenThenAssert" }; + +tester.run("anti-slop/no-widen-then-assert", noWidenThenAssertRule, { + valid: [ + "const source = { id: 'first' }; const widened: unknown = source;", + "declare const input: unknown; const parsed = input as { readonly id: string };", + ], + invalid: [ + { + code: "const source = { id: 'second' }; const widened: unknown = source; const parsed = widened as { readonly id: string };", + errors: [error], + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000..c5e07f7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts new file mode 100644 index 0000000..bdfa7bb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.test.ts @@ -0,0 +1,72 @@ +import { RuleTester } from "oxlint/plugins-dev"; + +import { requireSafetyCommentForTypeAssertionRule } from "./require-safety-comment-for-type-assertion.ts"; + +const tester = new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }); +const error = { messageId: "missingSafetyComment" }; + +tester.run( + "anti-slop/require-safety-comment-for-type-assertion (custom markers)", + requireSafetyCommentForTypeAssertionRule, + { + valid: [ + { + code: "// INVARIANT: The caller parsed this value.\nconst value = input as User;", + options: [{ markers: ["INVARIANT"] }], + }, + { + code: "// SAFETY: The caller parsed this value.\nconst value = input as User;", + options: [{ markers: ["INVARIANT", "SAFETY"] }], + }, + { + code: "// SAFE+: The caller parsed this value.\nconst value = input as User;", + options: [{ markers: ["SAFE+"] }], + }, + ], + invalid: [ + { + code: "// SAFETY: This marker is not configured.\nconst value = input as User;", + options: [{ markers: ["INVARIANT"] }], + errors: [error], + }, + { + code: "// INVARIANT: \nconst value = input as User;", + options: [{ markers: ["INVARIANT"] }], + errors: [error], + }, + ], + }, +); + +tester.run( + "anti-slop/require-safety-comment-for-type-assertion", + requireSafetyCommentForTypeAssertionRule, + { + valid: [ + "const values = [1, 2] as const;", + "const value = { id: 'one' };", + "// SAFETY: The parser established the UserId invariant.\nconst id = value as UserId;", + "function parse(): UserId {\n// SAFETY: Validation above established the UserId invariant.\nreturn value as UserId;\n}", + "const id = /* SAFETY: Validation established the invariant. */ value as UserId;", + "// SAFETY: The parser established the exported UserId invariant.\nexport const id = value as UserId;", + "/* SAFETY:\n * The parser established the exported UserId invariant.\n */\nexport const id = value as UserId;", + ], + invalid: [ + { code: "const id = value as UserId;", errors: [error] }, + { code: "const id = value;", errors: [error] }, + { code: "const id = value as UserId; // SAFETY: Too late.", errors: [error] }, + { + code: "// This cast seems fine.\nconst id = value as UserId;", + errors: [error], + }, + { code: "// SAFETY:\nconst id = value as UserId;", errors: [error] }, + { code: "// SAFETY: \nconst id = value as UserId;", errors: [error] }, + { code: "const id = /* SAFETY: */ value as UserId;", errors: [error] }, + { code: "export const id = value as UserId;", errors: [error] }, + { + code: "// This is not a safety justification.\nexport const id = value as UserId;", + errors: [error], + }, + ], + }, +); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..bfea1a2 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,131 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const; + +const commentOwnerKinds = new Set([ + "ExpressionStatement", + "PropertyDefinition", + "ReturnStatement", + "ThrowStatement", + "VariableDeclaration", +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === "TSTypeReference" && + node.typeAnnotation.typeName.type === "Identifier" && + node.typeAnnotation.typeName.name === "const" + ); +} + +function configuredSafetyMarkers(option: unknown): readonly string[] { + if (typeof option !== "object" || option === null || !("markers" in option)) { + return DEFAULT_SAFETY_MARKERS; + } + const configured = option.markers; + if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS; + const markers = configured.flatMap((marker) => + typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [], + ); + return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS; +} + +function markerPattern(markers: readonly string[]): RegExp { + const alternation = markers + .map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)) + .join("|"); + return new RegExp( + String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`, + "u", + ); +} + +function hasSafetyJustificationBefore( + sourceCode: SourceCode, + owner: ESTree.Node, + assertion: TypeAssertion, + pattern: RegExp, +): boolean { + return sourceCode + .getCommentsBefore(owner) + .some( + (comment) => comment.end <= assertion.start && pattern.test(comment.value), + ); +} + +function hasSafetyComment( + sourceCode: SourceCode, + node: TypeAssertion, + pattern: RegExp, +): boolean { + let current: ESTree.Node = node; + while (true) { + if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true; + if (commentOwnerKinds.has(current.type)) { + const exportDeclaration = current.parent; + return ( + exportDeclaration.type === "ExportNamedDeclaration" && + exportDeclaration.declaration === current && + hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern) + ); + } + if (current.parent.type === "Program") return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.", + }, + messages: { + missingSafetyComment: + "This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + schema: [ + { + type: "object", + properties: { + markers: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ markers: ["SAFETY"] }], + }, + createOnce(context) { + const patterns = new Map(); + + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node)) return; + const markers = configuredSafetyMarkers(context.options?.[0]); + const patternKey = markers.join("\u0000"); + const pattern = patterns.get(patternKey) ?? markerPattern(markers); + patterns.set(patternKey, pattern); + if (hasSafetyComment(context.sourceCode, node, pattern)) return; + context.report({ + node, + messageId: "missingSafetyComment", + data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] }, + }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/array-method.ts b/tools/oxlint/anti-slop/shared/array-method.ts new file mode 100644 index 0000000..87eb9d8 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/array-method.ts @@ -0,0 +1,94 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */ +export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node { + while ( + node.type === "ParenthesizedExpression" || + node.type === "ChainExpression" || + node.type === "TSAsExpression" || + node.type === "TSTypeAssertion" || + node.type === "TSNonNullExpression" || + node.type === "TSSatisfiesExpression" + ) { + node = node.expression; + } + return node; +} + +/** Resolve a local binding by scope, not by identifier spelling. */ +export function resolveArrayBinding(sourceCode: SourceCode, node: ESTree.Node): Variable | null { + node = unwrapArrayExpression(node); + if (node.type !== "Identifier") return null; + let scope: Scope | null = sourceCode.getScope(node); + while (scope !== null) { + const variable = scope.set.get(node.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +/** Read static method names, including computed string literals, without evaluating expressions. */ +export function arrayMethodTarget( + node: ESTree.Node, +): { readonly name: string; readonly object: ESTree.Node } | null { + node = unwrapArrayExpression(node); + if (node.type !== "MemberExpression") return null; + const property = node.property; + if (!node.computed && property.type === "Identifier") { + return { name: property.name, object: node.object }; + } + if (node.computed && property.type === "Literal" && typeof property.value === "string") { + return { name: property.value, object: node.object }; + } + return null; +} + +function isArrayAnnotation(type: ESTree.TSType): boolean { + if (type.type === "TSArrayType" || type.type === "TSTupleType") return true; + if (type.type === "TSParenthesizedType") return isArrayAnnotation(type.typeAnnotation); + if (type.type === "TSTypeOperator" && type.operator === "readonly") { + return isArrayAnnotation(type.typeAnnotation); + } + return ( + type.type === "TSTypeReference" && type.typeName.type === "Identifier" && + (type.typeName.name === "Array" || type.typeName.name === "ReadonlyArray") + ); +} + +/** Recognize local array evidence; unknown receivers and iterator pipelines are deliberately excluded. */ +export function isKnownArrayExpression( + sourceCode: SourceCode, + node: ESTree.Node, + visited = new Set(), +): boolean { + node = unwrapArrayExpression(node); + if (node.type === "ArrayExpression") return true; + if (node.type === "CallExpression") { + const method = arrayMethodTarget(node.callee); + return ( + method !== null && + ["map", "filter", "flatMap", "slice", "concat", "toSorted", "toReversed", "toSpliced"].includes(method.name) && + isKnownArrayExpression(sourceCode, method.object, visited) + ); + } + if (node.type !== "Identifier") return false; + const variable = resolveArrayBinding(sourceCode, node); + if (variable === null || visited.has(variable)) return false; + visited.add(variable); + if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false; + for (const identifier of variable.identifiers) { + const annotation = identifier.typeAnnotation?.typeAnnotation; + if (annotation !== undefined) return isArrayAnnotation(annotation); + } + for (const definition of variable.defs) { + if ( + definition.type === "Variable" && definition.node.type === "VariableDeclarator" && + definition.node.id.type === "Identifier" && definition.node.init !== null && + definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const" + ) { + return isKnownArrayExpression(sourceCode, definition.node.init, visited); + } + } + return false; +} diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000..8db73ff --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,515 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { + createTypeAliasEnvironment, + hasVisibleTypeBinding, + visibleTypeAlias, + type TypeAliasEnvironment as LexicalTypeAliasEnvironment, +} from "./type-alias-resolution.ts"; + +const BUILT_INS = new Set([ + "Record", + "Readonly", + "Partial", + "Required", + "Pick", + "Omit", + "PropertyKey", + "NonNullable", +]); +const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: "unsafe-dictionary"; + readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; +}; + +export type WideningTargetKind = + | "anonymous object" + | "generic container" + | "object" + | "open dictionary" + | "unknown"; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly interfaces: ReadonlyMap; + readonly typeAliases: LexicalTypeAliasEnvironment; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment( + program: ESTree.Program, + visitorKeys: Readonly>, +): TypeEnvironment { + const interfaces = new Map(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type !== "TSInterfaceDeclaration") continue; + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + } + + return { + interfaces, + typeAliases: createTypeAliasEnvironment(program, visitorKeys), + }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn( + name: string, + use: ESTree.Node, + environment: TypeEnvironment, +): boolean { + return ( + BUILT_INS.has(name) && + !hasVisibleTypeBinding(name, use, environment.typeAliases) + ); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === "TSTypeReference" && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === "TSParenthesizedType" || + (current.type === "TSTypeOperator" && current.operator === "readonly") + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === "TSNeverKeyword"; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === "TSPropertySignature" && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[], +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== "TSTypeReference") return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary["unsafeValue"] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return "unknown"; + if (unwrapped.type === "TSAnyKeyword") return "any"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) + return "empty-object"; + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? "union" + : null; + } + if (unwrapped.type === "TSIntersectionType") { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes("any")) return "any"; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === "TSIndexSignature" && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === "TSMappedType") { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== "TSTypeReference") return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ( + (name === "Pick" || name === "Omit") && + isBuiltIn(name, unwrapped, environment) + ) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; + } + return null; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : unwrapped.members.length > 0 + ? { kind: "anonymous object" } + : null; + } + if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, new Map()) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + const resolved = + substitutions === null + ? null + : classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; +} + +function hasBroadRecordKey( + type: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const key = type.typeArguments?.params[0]; + return key === undefined || isBroadMappedKey(key, environment, substitutions); +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + visitedAliases: ReadonlySet = new Set(), +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.some((member) => + isBroadMappedKey(member, environment, substitutions, visitedAliases), + ); + } + if (unwrapped.type !== "TSTypeReference") return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions, visitedAliases); + } + if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true; + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if ( + alias === null || + (alias.typeParameters?.params.length ?? 0) > 0 || + visitedAliases.has(name) + ) { + return false; + } + const nextVisited = new Set(visitedAliases); + nextVisited.add(name); + return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; + if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type === "TSIndexSignature") + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type === "TSMappedType") { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + if (unwrapped.type !== "TSTypeReference") return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget( + substitution, + environment, + substitutions, + resolvingAliases, + ); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, unwrapped, environment)) { + return hasBroadRecordKey(unwrapped, environment, substitutions) + ? { kind: "open dictionary" } + : null; + } + const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases); + if (alias === null || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving, + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current.type === "ObjectExpression" && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" + ) { + current = current.expression; + } + if (current.type === "ObjectExpression") return true; + return ( + current.type === "ArrayExpression" || + current.type === "ArrowFunctionExpression" || + current.type === "ClassExpression" || + current.type === "FunctionExpression" || + current.type === "NewExpression" || + current.type === "Literal" || + current.type === "TemplateLiteral" || + current.type === "UnaryExpression" + ); +} diff --git a/tools/oxlint/anti-slop/shared/function-parameters.ts b/tools/oxlint/anti-slop/shared/function-parameters.ts new file mode 100644 index 0000000..80de91d --- /dev/null +++ b/tools/oxlint/anti-slop/shared/function-parameters.ts @@ -0,0 +1,49 @@ +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +export type FunctionParameter = ESTree.ParamPattern; + +/** Return whether a type is or contains TypeScript's absorbing unknown top type. */ +export function containsUnknownType(type: ESTree.TSType): boolean { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation); + return type.type === "TSUnionType" && type.types.some(containsUnknownType); +} + +/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */ +export function functionParameterTypeAnnotation( + parameter: FunctionParameter, +): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return functionParameterTypeAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left); + } + return parameter.typeAnnotation; +} + +/** Return only a function parameter's local binding, excluding its annotation and default value. */ +export function functionParameterBindingName( + parameter: FunctionParameter, + sourceCode: SourceCode, +): string { + if (parameter.type === "TSParameterProperty") { + return functionParameterBindingName(parameter.parameter, sourceCode); + } + if (parameter.type === "AssignmentPattern") { + return functionParameterBindingName(parameter.left, sourceCode); + } + if (parameter.type === "RestElement") { + return functionParameterBindingName(parameter.argument, sourceCode); + } + if (parameter.type === "Identifier") return parameter.name; + + const sourceText = sourceCode.getText(parameter); + const annotationStart = parameter.typeAnnotation?.start; + return annotationStart === undefined + ? sourceText + : sourceText.slice(0, annotationStart - parameter.start).trimEnd(); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 0000000..7cdb18c --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 0000000..39bc218 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/tools/oxlint/anti-slop/shared/type-alias-resolution.ts b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts new file mode 100644 index 0000000..4744222 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/type-alias-resolution.ts @@ -0,0 +1,250 @@ +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts"; + +type VisitorKeys = Readonly>; +type TypeScope = ESTree.Node; + +type TypeBinding = { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; + readonly scope: TypeScope; +}; + +type Substitution = { + readonly substitutions: Substitutions; + readonly type: ESTree.TSType; +}; + +type Substitutions = ReadonlyMap; + +export type TypeAliasEnvironment = { + readonly aliases: readonly ESTree.TSTypeAliasDeclaration[]; + readonly bindingsByName: ReadonlyMap; + readonly visitorKeys: VisitorKeys; +}; + +export type ResolvedTypeMatcher = ( + type: ESTree.TSType, + matches: (child: ESTree.TSType) => boolean, +) => boolean; + +const environmentsByProgram = new WeakMap(); + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function enclosingTypeScope(node: ESTree.Node): TypeScope { + let current: ESTree.Node | null = node.parent; + while (current !== null) { + if ( + current.type === "Program" || + current.type === "BlockStatement" || + current.type === "TSModuleBlock" || + current.type === "StaticBlock" || + current.type === "SwitchStatement" + ) { + return current; + } + current = current.parent; + } + return node; +} + +function declaredTypeBinding(node: ESTree.Node): { + readonly alias: ESTree.TSTypeAliasDeclaration | null; + readonly name: string; +} | null { + if (node.type === "TSTypeAliasDeclaration") { + return { alias: node, name: node.id.name }; + } + if ( + node.type === "TSInterfaceDeclaration" || + node.type === "TSEnumDeclaration" || + node.type === "ClassDeclaration" || + node.type === "ClassExpression" + ) { + return node.id === null ? null : { alias: null, name: node.id.name }; + } + if ( + node.type === "ImportSpecifier" || + node.type === "ImportDefaultSpecifier" || + node.type === "ImportNamespaceSpecifier" + ) { + return { alias: null, name: node.local.name }; + } + return null; +} + +function collectTypeBindings( + node: ESTree.Node, + visitorKeys: VisitorKeys, + bindingsByName: Map, + aliases: ESTree.TSTypeAliasDeclaration[], +): void { + const declared = declaredTypeBinding(node); + if (declared !== null) { + const bindings = bindingsByName.get(declared.name) ?? []; + bindings.push({ ...declared, scope: enclosingTypeScope(node) }); + bindingsByName.set(declared.name, bindings); + if (declared.alias !== null) aliases.push(declared.alias); + } + + // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties. + const fields = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = fields[key]; + if (isNode(value)) { + collectTypeBindings(value, visitorKeys, bindingsByName, aliases); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) { + collectTypeBindings(child, visitorKeys, bindingsByName, aliases); + } + } + } +} + +/** Collect every lexical type alias and competing type binding in a program. */ +export function createTypeAliasEnvironment( + program: ESTree.Program, + visitorKeys: VisitorKeys, +): TypeAliasEnvironment { + const cached = environmentsByProgram.get(program); + if (cached !== undefined) return cached; + const bindingsByName = new Map(); + const aliases: ESTree.TSTypeAliasDeclaration[] = []; + collectTypeBindings(program, visitorKeys, bindingsByName, aliases); + const environment = { aliases, bindingsByName, visitorKeys }; + environmentsByProgram.set(program, environment); + return environment; +} + +function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null { + let current: ESTree.Node | null = node; + let distance = 0; + while (current !== null) { + if (current === ancestor) return distance; + current = current.parent; + distance += 1; + } + return null; +} + +function nearestTypeBindings( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): readonly TypeBinding[] { + const candidates = environment.bindingsByName.get(name) ?? []; + let nearestDistance = Number.POSITIVE_INFINITY; + let nearest: TypeBinding[] = []; + for (const candidate of candidates) { + const distance = ancestorDistance(candidate.scope, use); + if (distance === null || distance > nearestDistance) continue; + if (distance === nearestDistance) { + nearest.push(candidate); + continue; + } + nearestDistance = distance; + nearest = [candidate]; + } + return nearest; +} + +/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */ +export function visibleTypeAlias( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): ESTree.TSTypeAliasDeclaration | null { + if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null; + const bindings = nearestTypeBindings(name, use, environment); + return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null; +} + +/** Return whether a local declaration shadows a built-in type at this use. */ +export function hasVisibleTypeBinding( + name: string, + use: ESTree.Node, + environment: TypeAliasEnvironment, +): boolean { + return ( + lexicalTypeParameterNames(use, environment.visitorKeys).has(name) || + nearestTypeBindings(name, use, environment).length > 0 + ); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function aliasSubstitutions( + alias: ESTree.TSTypeAliasDeclaration, + reference: ESTree.TSTypeReference, + base: Substitutions, +): Substitutions | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = reference.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const explicitArgument = arguments_[index]; + const argument = explicitArgument ?? parameter.default; + if (argument === null || argument === undefined) return null; + const argumentSubstitutions = explicitArgument === undefined ? next : base; + next.set(parameter.name.name, { + type: argument, + substitutions: new Map(argumentSubstitutions), + }); + } + return next; +} + +/** Match a type after resolving visible aliases and substituting their type parameters. */ +export function resolvedTypeMatches( + type: ESTree.TSType, + environment: TypeAliasEnvironment, + matcher: ResolvedTypeMatcher, +): boolean { + const evaluate = ( + current: ESTree.TSType, + substitutions: Substitutions, + resolvingAliases: ReadonlySet, + ): boolean => { + if (current.type === "TSTypeReference") { + const name = typeReferenceName(current); + if (name !== null) { + const substitution = substitutions.get(name); + if (substitution !== undefined && !current.typeArguments?.params.length) { + return evaluate( + substitution.type, + substitution.substitutions, + resolvingAliases, + ); + } + const alias = visibleTypeAlias(name, current, environment); + if (alias !== null && !resolvingAliases.has(alias)) { + const nextSubstitutions = aliasSubstitutions(alias, current, substitutions); + if (nextSubstitutions !== null) { + const nextResolving = new Set(resolvingAliases); + nextResolving.add(alias); + return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving); + } + } + } + } + return matcher(current, (child) => + evaluate(child, substitutions, resolvingAliases), + ); + }; + + return evaluate(type, new Map(), new Set()); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8464ec1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["index.ts", "src", "scripts"] +}