diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 000000000..62136e654 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + ignorePatterns: [ + ".agent/**", + ".agents/**", + ".claude/**", + ".codex/**", + ".continue/**", + ".cursor/**", + ".gemini/**", + ".opencode/**", + ".pi/**", + ".roo/**", + ".windsurf/**", + "tools/oxlint/anti-slop/**", + ], + jsPlugins: [ + { name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" }, + ], + rules: { + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-conditional-empty-object-spread": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-module-mocking": "error", + "anti-slop/no-object-parameters": "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/package-lock.json b/package-lock.json index 79993030d..1a4c847d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "devspace": "dist/cli.js" }, "devDependencies": { + "@oxlint/plugins": "^1.78.0", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^25.9.1", @@ -41,6 +42,7 @@ "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.78.0", "tsx": "^4.22.3", "typescript": "^6.0.3", "vite": "^8.0.14" @@ -2796,6 +2798,342 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.78.0.tgz", + "integrity": "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.78.0.tgz", + "integrity": "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.78.0.tgz", + "integrity": "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.78.0.tgz", + "integrity": "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.78.0.tgz", + "integrity": "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.78.0.tgz", + "integrity": "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.78.0.tgz", + "integrity": "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.78.0.tgz", + "integrity": "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.78.0.tgz", + "integrity": "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.78.0.tgz", + "integrity": "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.78.0.tgz", + "integrity": "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.78.0.tgz", + "integrity": "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.78.0.tgz", + "integrity": "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.78.0.tgz", + "integrity": "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.78.0.tgz", + "integrity": "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.78.0.tgz", + "integrity": "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.78.0.tgz", + "integrity": "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.78.0.tgz", + "integrity": "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==", + "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.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.78.0.tgz", + "integrity": "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.78.0.tgz", + "integrity": "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pierre/diffs": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.5.tgz", @@ -5127,6 +5465,55 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/oxlint": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.78.0.tgz", + "integrity": "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.78.0", + "@oxlint/binding-android-arm64": "1.78.0", + "@oxlint/binding-darwin-arm64": "1.78.0", + "@oxlint/binding-darwin-x64": "1.78.0", + "@oxlint/binding-freebsd-x64": "1.78.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", + "@oxlint/binding-linux-arm-musleabihf": "1.78.0", + "@oxlint/binding-linux-arm64-gnu": "1.78.0", + "@oxlint/binding-linux-arm64-musl": "1.78.0", + "@oxlint/binding-linux-ppc64-gnu": "1.78.0", + "@oxlint/binding-linux-riscv64-gnu": "1.78.0", + "@oxlint/binding-linux-riscv64-musl": "1.78.0", + "@oxlint/binding-linux-s390x-gnu": "1.78.0", + "@oxlint/binding-linux-x64-gnu": "1.78.0", + "@oxlint/binding-linux-x64-musl": "1.78.0", + "@oxlint/binding-openharmony-arm64": "1.78.0", + "@oxlint/binding-win32-arm64-msvc": "1.78.0", + "@oxlint/binding-win32-ia32-msvc": "1.78.0", + "@oxlint/binding-win32-x64-msvc": "1.78.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index 129839120..ab93fcd2c 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json", "build:app": "vite build", "dev": "node scripts/dev-server.mjs", + "lint": "oxlint", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", @@ -56,6 +57,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@oxlint/plugins": "^1.78.0", "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/node": "^25.9.1", @@ -63,6 +65,7 @@ "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", "@vitejs/plugin-react": "^6.0.2", + "oxlint": "^1.78.0", "tsx": "^4.22.3", "typescript": "^6.0.3", "vite": "^8.0.14" diff --git a/src/apply-patch.ts b/src/apply-patch.ts index 05a73de63..4aa3cf463 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -210,6 +210,7 @@ async function resolveConfinedPath(root: string, input: string): Promise } break; } catch (error) { + // SAFETY: filesystem failures expose Node's errno code used for the missing-parent case. const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") throw error; const parent = dirname(existing); @@ -221,7 +222,7 @@ async function resolveConfinedPath(root: string, input: string): Promise return target; } -function splitFile(content: string): { lines: string[]; eol: string; finalNewline: boolean } { +function splitFile(content: string) { const eol = content.includes("\r\n") ? "\r\n" : "\n"; const normalized = content.replace(/\r\n/g, "\n"); const finalNewline = normalized.endsWith("\n"); @@ -334,6 +335,7 @@ export async function isSamePatchFile( ]); return sourceIdentity.dev === destinationIdentity.dev && sourceIdentity.ino === destinationIdentity.ino; } catch (error) { + // SAFETY: filesystem failures expose Node's errno code used for missing paths. const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === "ENOTDIR") return false; throw error; @@ -474,7 +476,7 @@ function stripFinalNewline(value: string): string { return value; } -function countPatchStats(patch: string): { additions: number; removals: number } { +function countPatchStats(patch: string) { let additions = 0; let removals = 0; for (const line of patch.split("\n")) { diff --git a/src/artifact-download.test.ts b/src/artifact-download.test.ts index c49aad84b..db29838a6 100644 --- a/src/artifact-download.test.ts +++ b/src/artifact-download.test.ts @@ -50,23 +50,32 @@ try { } function testOneToolContract(): void { - const registered = new Map; callback: (input: never) => unknown }>(); + type ToolDescriptor = { + _meta?: object; + inputSchema?: Parameters[0]; + outputSchema?: object; + annotations?: { destructiveHint?: boolean }; + }; + const registered = new Map void }>(); const server = { registerTool( name: string, - descriptor: Record, - callback: (input: never) => unknown, + descriptor: ToolDescriptor, + callback: (input: never) => void, ) { registered.set(name, { descriptor, callback }); return {}; }, }; + // SAFETY: this mock intentionally implements only the registerTool surface used by the contract test. registerArtifactTools(server as never, { config: { artifactMaxFileBytes: 1024, logging: { toolCalls: false }, + // SAFETY: the test exercises registration metadata and does not invoke the runtime config. } as never, + // SAFETY: the test does not execute the registered download callback. workspaces: {} as never, }); @@ -74,11 +83,14 @@ function testOneToolContract(): void { const descriptor = registered.get("download_artifact")?.descriptor; assert.ok(descriptor); assert.deepEqual(descriptor._meta, { "openai/fileParams": ["file"] }); - assert.deepEqual(Object.keys(descriptor.inputSchema as object).sort(), ["file", "path", "workspaceId"]); - assert.deepEqual(Object.keys(descriptor.outputSchema as object), ["path"]); - assert.equal((descriptor.annotations as { destructiveHint?: boolean }).destructiveHint, false); - - const fileSchema = (descriptor.inputSchema as z.ZodRawShape).file as z.ZodType; + assert.deepEqual(Object.keys(descriptor.inputSchema ?? {}).sort(), ["file", "path", "workspaceId"]); + assert.deepEqual(Object.keys(descriptor.outputSchema ?? {}), ["path"]); + assert.equal(descriptor.annotations?.destructiveHint, false); + + const inputSchema = descriptor.inputSchema; + assert.ok(inputSchema); + const fileSchema = inputSchema.file; + assert.ok(fileSchema); const valid = { download_url: "https://files.oaiusercontent.com/file_123/download?sig=secret", file_id: "file_123", @@ -383,6 +395,6 @@ function registryFor(source: { async function expectArtifactError(promise: Promise, code: string): Promise { await assert.rejects( promise, - (error: unknown) => error instanceof ArtifactError && error.code === code, + (error: Error) => error instanceof ArtifactError && error.code === code, ); } diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index fe9a195af..00f8ce429 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -22,6 +22,7 @@ import { } from "./incoming-artifacts.js"; import { logEvent } from "./logger.js"; import type { WorkspaceRegistry } from "./workspaces.js"; +import { isObject, isString } from "./value-types.js"; const ARTIFACT_WRITE_ANNOTATIONS = { readOnlyHint: false, @@ -58,6 +59,8 @@ export interface DownloadIncomingArtifactInput { path: string; } +type ArtifactToolInput = DownloadIncomingArtifactInput; + export interface DownloadIncomingArtifactResult { path: string; size: number; @@ -285,12 +288,10 @@ export async function downloadIncomingArtifact({ } } -export function artifactToolLogFields( - input: Record, -): Record { +export function artifactToolLogFields(input: ArtifactToolInput) { return { fileProvided: input.file !== undefined, - fileReferenceShape: describeIncomingArtifactValue(input.file), + fileReferenceDescription: describeIncomingArtifactValue(input.file), downloadUrlHostname: incomingFileDownloadHostname(input.file), workspaceId: input.workspaceId, path: input.path, @@ -299,7 +300,7 @@ export function artifactToolLogFields( async function executeArtifactTool( config: ServerConfig, - input: Record, + input: ArtifactToolInput, operation: () => Promise<{ publicResult: { path: string }; logResult: DownloadIncomingArtifactResult; @@ -572,12 +573,12 @@ async function writeAll( } } -function incomingFileDownloadHostname(value: unknown): string | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { +function incomingFileDownloadHostname(value: T): string | undefined { + if (!isObject(value) || Array.isArray(value)) { return undefined; } - const rawUrl = (value as Record).download_url; - if (typeof rawUrl !== "string") return undefined; + const rawUrl = "download_url" in value ? value.download_url : undefined; + if (!isString(rawUrl)) return undefined; try { const hostname = new URL(rawUrl).hostname.toLowerCase(); return hostname.length > 0 && hostname.length <= 253 ? hostname : undefined; @@ -586,9 +587,9 @@ function incomingFileDownloadHostname(value: unknown): string | undefined { } } -function incomingStreamChunk(value: unknown): Buffer { +function incomingStreamChunk(value: T): Buffer { if (Buffer.isBuffer(value)) return value; - if (typeof value === "string") return Buffer.from(value); + if (isString(value)) return Buffer.from(value); if (value instanceof Uint8Array) { return Buffer.from(value.buffer, value.byteOffset, value.byteLength); } @@ -607,6 +608,6 @@ async function lstatOrUndefined(path: string) { } } -function isNodeError(error: unknown): error is NodeJS.ErrnoException { +function isNodeError(error: T): error is T & NodeJS.ErrnoException { return error instanceof Error && "code" in error; } diff --git a/src/cli.test.ts b/src/cli.test.ts index 97b7084a8..fabe5236e 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { loadConfig } from "./config.js"; import { LocalAgentStore } from "./local-agent-store.js"; +// SAFETY: package.json is read from the package root and the test only consumes its version field. const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string; }; diff --git a/src/cli.ts b/src/cli.ts index 7a1ac63fe..f199e39ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { } from "./user-config.js"; import { expandHomePath } from "./roots.js"; import { shutdownHttpServer } from "./server-shutdown.js"; +import { isString, type JsonObject } from "./value-types.js"; type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version"; const require = createRequire(import.meta.url); @@ -541,7 +542,7 @@ function resolveCurrentWorkspaceRoot(): string { return resolve(process.env.DEVSPACE_WORKSPACE_ROOT || process.cwd()); } -function resolveCurrentWorkspaceScope(): { workspaceId?: string; workspaceRoot: string } { +function resolveCurrentWorkspaceScope() { return { workspaceId: process.env.DEVSPACE_WORKSPACE_ID, workspaceRoot: resolveCurrentWorkspaceRoot(), @@ -575,8 +576,9 @@ function printAgentsHelp(): void { } function printVersion(): void { - const packageJson = require("../package.json") as { version?: unknown }; - if (typeof packageJson.version !== "string") { + // SAFETY: package.json is loaded from this package root and only its version field is consumed. + const packageJson = require("../package.json") as JsonObject; + if (!isString(packageJson.version)) { throw new Error("Unable to read DevSpace package version."); } @@ -662,6 +664,7 @@ class SetupCancelledError extends Error {} function checkSqliteNative(): string { try { + // SAFETY: the dependency is loaded by the package setup and exposes the documented Database constructor. const Database = require("better-sqlite3") as typeof import("better-sqlite3"); const db = new Database(":memory:"); db.close(); @@ -673,6 +676,7 @@ function checkSqliteNative(): string { function checkGitAvailable(): string { try { + // SAFETY: Node's child_process module provides the documented execFileSync function. const { execFileSync } = require("node:child_process") as typeof import("node:child_process"); return execFileSync("git", ["--version"], { encoding: "utf8" }).trim(); } catch (error) { diff --git a/src/config.ts b/src/config.ts index f8c8b9954..9114f2f69 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,7 @@ export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; +const LOG_LEVELS = ["silent", "error", "warn", "debug"] as const satisfies readonly LogLevel[]; export interface ServerConfig { host: string; @@ -97,7 +98,8 @@ function parseToolMode(env: NodeJS.ProcessEnv): ToolMode { function parseLogLevel(value: string | undefined): LogLevel { if (!value || value === "info") return "info"; - if (["silent", "error", "warn", "debug"].includes(value)) return value as LogLevel; + const level = LOG_LEVELS.find((candidate) => candidate === value); + if (level) return level; throw new Error(`Invalid DEVSPACE_LOG_LEVEL: ${value}`); } diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1c5c3298a..b0f5a4b4b 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -41,6 +41,7 @@ export function migrateDatabase(sqlite: Database.Database): void { const applied = new Set( ( + // SAFETY: the query selects the numeric version column created above. sqlite.prepare("select version from devspace_schema_migrations").all() as Array<{ version: number; }> @@ -204,6 +205,7 @@ function addColumnIfMissing( column: string, definition: string, ): void { + // SAFETY: pragma table_info always includes the name column used by this migration. const columns = sqlite.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>; if (columns.some((existingColumn) => existingColumn.name === column)) return; diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts index 04986c9a5..d3746e88b 100644 --- a/src/git-worktrees.ts +++ b/src/git-worktrees.ts @@ -5,6 +5,7 @@ import { mkdir, realpath, rm, stat } from "node:fs/promises"; import { basename, join, relative, resolve } from "node:path"; import type { ServerConfig } from "./config.js"; import { assertAllowedPath, isPathInsideRoot } from "./roots.js"; +import { isObject } from "./value-types.js"; const execFileAsync = promisify(execFile); @@ -104,7 +105,7 @@ async function resolveGitRoot(path: string, allowedRoots: string[]): Promise { try { return (await git(["rev-parse", "--verify", `${baseRef}^{commit}`], sourceRoot)).trim(); - } catch (error) { + } catch { if (baseRef === "HEAD") { throw new GitWorktreeError( "GIT_REPOSITORY_HAS_NO_COMMITS", - "Cannot open workspace in worktree mode because the repository has no commits yet. Create an initial commit first, or use mode=\"checkout\".", + `Cannot open workspace in worktree mode because the repository has no commits yet. Create an initial commit first, or use mode="checkout".`, ); } @@ -169,22 +170,19 @@ async function git(args: string[], cwd: string): Promise { } catch (error) { if (isGitUnavailable(error)) throw error; - const stderr = typeof error === "object" && error && "stderr" in error - ? String((error as { stderr?: unknown }).stderr ?? "").trim() + const stderr = isObject(error) && "stderr" in error + ? String(error.stderr ?? "").trim() : ""; - const stdout = typeof error === "object" && error && "stdout" in error - ? String((error as { stdout?: unknown }).stdout ?? "").trim() + const stdout = isObject(error) && "stdout" in error + ? String(error.stdout ?? "").trim() : ""; const details = stderr || stdout || (error instanceof Error ? error.message : String(error)); throw new Error(details); } } -function isGitUnavailable(error: unknown): boolean { +function isGitUnavailable(error: T): boolean { return Boolean( - typeof error === "object" && - error && - "code" in error && - (error as { code?: unknown }).code === "ENOENT", + isObject(error) && "code" in error && error.code === "ENOENT", ); } diff --git a/src/incoming-artifacts.test.ts b/src/incoming-artifacts.test.ts index 7f126676a..4354a0e06 100644 --- a/src/incoming-artifacts.test.ts +++ b/src/incoming-artifacts.test.ts @@ -10,7 +10,7 @@ import { await testRegistryFailsClosed(); await testOpenAIFileAdapter(); -testLogShapeRedaction(); +testLogStructureRedaction(); async function testRegistryFailsClosed(): Promise { const registry = new IncomingArtifactAdapterRegistry(); @@ -45,11 +45,11 @@ async function testRegistryFailsClosed(): Promise { assert.throws( () => new IncomingArtifactAdapterRegistry([{ ...ambiguous, id: "UPPER" }]), - (error: unknown) => error instanceof ArtifactError && error.code === "invalid_incoming_adapter", + (error: Error) => error instanceof ArtifactError && error.code === "invalid_incoming_adapter", ); assert.throws( () => new IncomingArtifactAdapterRegistry([ambiguous, ambiguous]), - (error: unknown) => error instanceof ArtifactError && error.code === "duplicate_incoming_adapter", + (error: Error) => error instanceof ArtifactError && error.code === "duplicate_incoming_adapter", ); } @@ -192,7 +192,7 @@ async function testOpenAIFileAdapter(): Promise { ); } -function testLogShapeRedaction(): void { +function testLogStructureRedaction(): void { const value = { download_url: "https://files.oaiusercontent.com/file_123/download?sig=super-secret", file_id: "file_secret", @@ -219,6 +219,6 @@ async function collect(stream: Readable): Promise { async function expectArtifactError(promise: Promise, code: string): Promise { await assert.rejects( promise, - (error: unknown) => error instanceof ArtifactError && error.code === code, + (error: Error) => error instanceof ArtifactError && error.code === code, ); } diff --git a/src/incoming-artifacts.ts b/src/incoming-artifacts.ts index 0f9260855..6f9b99f74 100644 --- a/src/incoming-artifacts.ts +++ b/src/incoming-artifacts.ts @@ -2,6 +2,28 @@ import { basename, isAbsolute } from "node:path"; import { Readable } from "node:stream"; import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import { ArtifactError } from "./artifact-error.js"; +import { + isBigInt, + isBoolean, + isFunction, + isNumber, + isObject, + isString, + isSymbol, +} from "./value-types.js"; + +type IncomingValue = + | string + | number + | boolean + | bigint + | symbol + | null + | undefined + | (() => void) + | IncomingValue[] + | IncomingRecord; +type IncomingRecord = { [key: string]: IncomingValue }; const ADAPTER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; const OPENAI_FILE_HOSTS = new Set([ @@ -12,7 +34,7 @@ const OPENAI_FILE_HOSTS = new Set([ const OPENAI_REGIONAL_BLOB_HOST_PATTERN = /^oaisdmntpr[a-z0-9]+\.blob\.core\.windows\.net$/u; const OPENAI_FILENAME_SAFE_FILE_ID_PATTERN = /^file[-_][A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u; const OPENAI_FILE_ID_MAX_LENGTH = 512; -const OPENAI_FILE_ID_CONTROL_PATTERN = /[\u0000-\u001F\u007F]/u; +const OPENAI_FILE_ID_CONTROL_PATTERN = /\p{Cc}/u; const OPENAI_FILE_KEYS = new Set([ "download_url", "file_id", @@ -33,8 +55,8 @@ export interface IncomingArtifactSource { export interface IncomingArtifactAdapter { readonly id: string; - canHandle(value: unknown): boolean; - open(value: unknown): Promise; + canHandle(value: T): boolean; + open(value: T): Promise; } export interface OpenedIncomingArtifact extends IncomingArtifactSource { @@ -64,7 +86,7 @@ export class IncomingArtifactAdapterRegistry { this.adapters = [...adapters]; } - async open(value: unknown): Promise { + async open(value: T): Promise { const matching: IncomingArtifactAdapter[] = []; for (const adapter of this.adapters) { let handles = false; @@ -141,7 +163,7 @@ export function createOpenAIIncomingArtifactAdapter( return { id: "openai-file", canHandle: isOpenAIFileReferenceCandidate, - async open(value: unknown): Promise { + async open(value: T): Promise { const reference = normalizeOpenAIFileReference(value); let downloadUrl = validateOpenAIFileUrl(reference.download_url); @@ -197,45 +219,46 @@ export function createOpenAIIncomingArtifactAdapter( name: normalizeOpenAIFileName(reference.file_name, reference.file_id, mimeType), mimeType, size: responseSize ?? reference.size, - stream: Readable.fromWeb(response.body as unknown as NodeReadableStream), + // SAFETY: the Fetch response body is a WHATWG byte stream, which is the input contract of Readable.fromWeb. + stream: Readable.fromWeb(response.body as NodeReadableStream), }; }, }; } -export type IncomingArtifactValueShape = +export type IncomingArtifactValueDescription = | { type: "null" } | { type: "undefined" } | { type: "boolean" } | { type: "number"; finite: boolean } | { type: "bigint" } | { type: "string"; kind: "absolute-path" | "url" | "data-url" | "text"; length: number } - | { type: "array"; length: number; items: IncomingArtifactValueShape[]; truncated: boolean } + | { type: "array"; length: number; items: IncomingArtifactValueDescription[]; truncated: boolean } | { type: "object"; constructor?: string; - entries: Record; + entries: Record; truncated: boolean; } | { type: "function" | "symbol" } | { type: "cycle" }; -export function describeIncomingArtifactValue( - value: unknown, +export function describeIncomingArtifactValue( + value: T, maxDepth = 4, maxEntries = 20, -): IncomingArtifactValueShape { +): IncomingArtifactValueDescription { const seen = new WeakSet(); - const describe = (current: unknown, depth: number): IncomingArtifactValueShape => { + const describe = (current: IncomingValue, depth: number): IncomingArtifactValueDescription => { if (current === null) return { type: "null" }; if (current === undefined) return { type: "undefined" }; - if (typeof current === "boolean") return { type: "boolean" }; - if (typeof current === "number") return { type: "number", finite: Number.isFinite(current) }; - if (typeof current === "bigint") return { type: "bigint" }; - if (typeof current === "function") return { type: "function" }; - if (typeof current === "symbol") return { type: "symbol" }; - if (typeof current === "string") { + if (isBoolean(current)) return { type: "boolean" }; + if (isNumber(current)) return { type: "number", finite: Number.isFinite(current) }; + if (isBigInt(current)) return { type: "bigint" }; + if (isFunction(current)) return { type: "function" }; + if (isSymbol(current)) return { type: "symbol" }; + if (isString(current)) { return { type: "string", kind: classifyValueString(current), @@ -267,11 +290,12 @@ export function describeIncomingArtifactValue( truncated: keys.length > 0, }; } - const entries: Record = {}; + const entries: Record = {}; for (const [index, key] of keys.slice(0, maxEntries).entries()) { - let entryValue: unknown; + let entryValue: IncomingValue; try { - entryValue = (current as Record)[key]; + // SAFETY: Object.keys returned keys from this record, so indexed values are represented by IncomingValue. + entryValue = (current as IncomingRecord)[key]; } catch { entryValue = undefined; } @@ -285,23 +309,24 @@ export function describeIncomingArtifactValue( }; }; - return describe(value, 0); + // SAFETY: the describer handles every runtime representation and only reads object keys after checking the shape. + return describe(value as IncomingValue, 0); } function validateIncomingArtifactSource(source: IncomingArtifactSource): void { - if (!source || typeof source !== "object") { + if (!isObject(source)) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter returned an invalid source.", ); } - if (typeof source.name !== "string" || source.name.length === 0) { + if (!isString(source.name) || source.name.length === 0) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter must provide a filename.", ); } - if (source.mimeType !== undefined && typeof source.mimeType !== "string") { + if (source.mimeType !== undefined && !isString(source.mimeType)) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter returned an invalid MIME hint.", @@ -316,8 +341,9 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { "Incoming artifact adapter returned an invalid byte size.", ); } + // SAFETY: source.stream is validated as an async-readable stream immediately below. const stream = source.stream as Partial | undefined; - if (!stream || typeof stream[Symbol.asyncIterator] !== "function") { + if (!stream || !isFunction(stream[Symbol.asyncIterator])) { throw new ArtifactError( "invalid_incoming_artifact_source", "Incoming artifact adapter must provide an async-readable stream.", @@ -325,7 +351,7 @@ function validateIncomingArtifactSource(source: IncomingArtifactSource): void { } } -function isOpenAIFileReferenceCandidate(value: unknown): value is Record { +function isOpenAIFileReferenceCandidate(value: T): value is T & IncomingRecord { if (!isRecord(value)) return false; const keys = Object.keys(value); return keys.length >= 2 @@ -334,7 +360,7 @@ function isOpenAIFileReferenceCandidate(value: unknown): value is Record(value: T): OpenAIFileReference { if (!isOpenAIFileReferenceCandidate(value)) { throw new ArtifactError( "invalid_openai_file_reference", @@ -345,8 +371,8 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { const downloadUrl = value.download_url; const fileId = value.file_id; if ( - typeof downloadUrl !== "string" - || typeof fileId !== "string" + !isString(downloadUrl) + || !isString(fileId) || !isValidOpenAIFileId(fileId) ) { throw new ArtifactError( @@ -380,7 +406,7 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { let size: number | undefined; const rawSize = value.size; if (rawSize !== undefined && rawSize !== null) { - if (typeof rawSize !== "number" || !Number.isSafeInteger(rawSize) || rawSize < 0) { + if (!isNumber(rawSize) || !Number.isSafeInteger(rawSize) || rawSize < 0) { throw new ArtifactError( "invalid_openai_file_reference", "ChatGPT file reference is malformed.", @@ -398,9 +424,9 @@ function normalizeOpenAIFileReference(value: unknown): OpenAIFileReference { }; } -function nullableString(value: unknown): string | undefined | null { +function nullableString(value: T): string | undefined | null { if (value === undefined || value === null) return undefined; - return typeof value === "string" ? value : null; + return isString(value) ? value : null; } function normalizeOpenAIFileName( @@ -495,8 +521,8 @@ function responseContentLength(response: Response): number | undefined { return Number.isSafeInteger(size) ? size : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function isRecord(value: T): value is T & IncomingRecord { + return isObject(value) && !Array.isArray(value); } function classifyValueString( @@ -519,10 +545,10 @@ function safeValueEntryKey(value: string, index: number): string { : ``; } -function safeConstructorName(value: object): string | undefined { +function safeConstructorName(value: { constructor?: { name?: string } }): string | undefined { try { const name = value.constructor?.name; - return typeof name === "string" && name.length <= 80 ? name : undefined; + return isString(name) && name.length <= 80 ? name : undefined; } catch { return undefined; } diff --git a/src/local-agent-adapters.test.ts b/src/local-agent-adapters.test.ts index 0e4cbf0fa..44f303ac2 100644 --- a/src/local-agent-adapters.test.ts +++ b/src/local-agent-adapters.test.ts @@ -26,7 +26,7 @@ const providers: LocalAgentProvider[] = [ for (const provider of providers) { const adapter = createLocalAgentAdapter(provider); assert.equal(adapter.provider, provider); - assert.equal(typeof adapter.run, "function"); + assert.ok(adapter.run); } assert.deepEqual( diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 457b8e08e..a9ef07590 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -1,5 +1,4 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { resolve } from "node:path"; import { Readable, Writable } from "node:stream"; import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; @@ -9,16 +8,37 @@ import { type LocalAgentRunInput, type LocalAgentRunResult, } from "./local-agent-runtime.js"; +import { isObject, isString } from "./value-types.js"; + +type ProviderValue = + | string + | number + | boolean + | null + | undefined + | ProviderValue[] + | ProviderRecord; +type ProviderRecord = { [key: string]: ProviderValue }; +type ProviderRequest = { [key: string]: ProviderValue }; + +interface OpenCodeClient { + session: { + create(parameters?: ProviderRequest, options?: ProviderRequest): Promise; + prompt(parameters?: ProviderRequest, options?: ProviderRequest): Promise; + wait?(parameters?: ProviderRequest, options?: ProviderRequest): Promise; + messages?(parameters?: ProviderRequest, options?: ProviderRequest): Promise; + }; +} export interface LocalAgentAdapter { readonly provider: LocalAgentProvider; run(input: LocalAgentRunInput): Promise; } -const ACP_COMMANDS: Record<"cursor" | "copilot", [string, ...string[]]> = { +const ACP_COMMANDS = { cursor: ["cursor-agent", "acp"], copilot: ["copilot", "--acp"], -}; +} satisfies Record<"cursor" | "copilot", [string, ...string[]]>; const PI_AGENT_TIMEOUT_MS = 120_000; export async function runLocalAgentProvider( @@ -64,12 +84,15 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { options: { cwd: input.workspace, model: input.model, - ...(input.thinking ? { thinking: { type: "adaptive" } as const, effort: input.thinking as EffortLevel } : {}), + // SAFETY: Claude's SDK accepts the adaptive thinking object when the profile enables thinking. + thinking: input.thinking ? { type: "adaptive" } as const : undefined, + // SAFETY: Claude's SDK uses EffortLevel for the profile's validated thinking value. + effort: input.thinking as EffortLevel | undefined, resume: input.providerSessionId, permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, env: claudeCommandEnvironment(process.env), - ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}), + pathToClaudeCodeExecutable: claudeExecutable, }, }); @@ -78,9 +101,10 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { const items: unknown[] = []; for await (const message of messages) { items.push(message); - const record = message as Record; - if (typeof record.session_id === "string") providerSessionId = record.session_id; - if (record.type === "result" && typeof record.result === "string") { + // SAFETY: Claude emits JSON-shaped result messages; only the fields below are consumed. + const record = message as ProviderRecord; + if (isString(record.session_id)) providerSessionId = record.session_id; + if (record.type === "result" && isString(record.result)) { const resultError = claudeResultError(record); if (resultError) throw new Error(resultError); finalResponse = record.result; @@ -97,8 +121,8 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter { } } -function claudeResultError(record: Record): string | undefined { - const subtype = typeof record.subtype === "string" ? record.subtype : undefined; +function claudeResultError(record: ProviderRecord): string | undefined { + const subtype = isString(record.subtype) ? record.subtype : undefined; const isError = record.is_error === true || subtype?.startsWith("error"); if (!isError) return undefined; const message = @@ -110,14 +134,13 @@ function claudeResultError(record: Record): string | undefined return `Claude returned an error result: ${message}`; } -function directString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; +function directString(value: ProviderValue): string | undefined { + return isString(value) && value.trim() ? value.trim() : undefined; } function resolveExecutable(command: string): string | undefined { - const result = spawnSync(process.platform === "win32" ? "where.exe" : "command", [ - ...(process.platform === "win32" ? [command] : ["-v", command]), - ], { + const args = process.platform === "win32" ? [command] : ["-v", command]; + const result = spawnSync(process.platform === "win32" ? "where.exe" : "command", args, { encoding: "utf8", shell: process.platform !== "win32", }); @@ -144,11 +167,42 @@ class OpencodeLocalAgentAdapter implements LocalAgentAdapter { async run(input: LocalAgentRunInput): Promise { const { createOpencode } = await import("@opencode-ai/sdk/v2"); const { client, server } = await createOpencode(); + const providerClient: OpenCodeClient = { + session: { + async create(parameters, options) { + // SAFETY: OpenCode accepts the JSON request assembled by this adapter. + const result = await client.session.create( + // SAFETY: OpenCode's generated client accepts this JSON-shaped request. + parameters as never, + // SAFETY: OpenCode's generated client accepts these request options. + options as never, + ); + return decodeProviderResponse(result); + }, + async prompt(parameters, options) { + // SAFETY: OpenCode accepts the JSON request assembled by this adapter. + const result = await client.session.prompt( + // SAFETY: OpenCode's generated client accepts this JSON-shaped request. + parameters as never, + // SAFETY: OpenCode's generated client accepts these request options. + options as never, + ); + return decodeProviderResponse(result); + }, + wait: undefined, + async messages(parameters, options) { + if (!client.session.messages) return undefined; + // SAFETY: OpenCode accepts the JSON request assembled by this adapter. + const result = await client.session.messages(parameters as never, options as never); + return decodeProviderResponse(result); + }, + }, + }; try { - const sessionId = input.providerSessionId ?? await createOpencodeSession(client, input); - const promptResult = await promptOpencodeSession(client, sessionId, input); - await waitForOpencodeSession(client, sessionId); - const messages = await readOpencodeMessages(client, sessionId); + const sessionId = input.providerSessionId ?? await createOpencodeSession(providerClient, input); + const promptResult = await promptOpencodeSession(providerClient, sessionId, input); + await waitForOpencodeSession(providerClient, sessionId); + const messages = await readOpencodeMessages(providerClient, sessionId); const finalResponse = requireFinalResponse( "OpenCode", extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), @@ -189,7 +243,9 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { }); const stream = ndJsonStream( + // SAFETY: assertPipedChild verified the child streams are writable/readable Node streams. Writable.toWeb(child.stdin) as WritableStream, + // SAFETY: assertPipedChild verified the child streams are writable/readable Node streams. Readable.toWeb(child.stdout) as ReadableStream, ); try { @@ -206,11 +262,11 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { providerSessionId = session.sessionId; try { if (input.model) { - const config = resolveAcpModelConfigUpdate(session, input.model, this.provider); + const config = resolveAcpModelConfigUpdate(decodeProviderResponse(session), input.model, this.provider); await context.request(methods.agent.session.setConfigOption, config); } if (input.thinking) { - const config = resolveAcpThinkingConfigUpdate(session, input.thinking, this.provider); + const config = resolveAcpThinkingConfigUpdate(decodeProviderResponse(session), input.thinking, this.provider); await context.request(methods.agent.session.setConfigOption, config); } const prompt = session.prompt(input.prompt); @@ -246,7 +302,7 @@ class AcpLocalAgentAdapter implements LocalAgentAdapter { } export function resolveAcpModelConfigUpdate( - session: unknown, + session: ProviderValue, model: string, provider: string, ): { sessionId: string; configId: string; value: string } { @@ -259,7 +315,7 @@ export function resolveAcpModelConfigUpdate( } export function resolveAcpThinkingConfigUpdate( - session: unknown, + session: ProviderValue, thinking: string, provider: string, ): { sessionId: string; configId: string; value: string } { @@ -272,17 +328,17 @@ export function resolveAcpThinkingConfigUpdate( } function resolveAcpSelectConfigUpdate( - session: unknown, + session: ProviderValue, options: { category: string; label: string; provider: string; value: string; }, -): { sessionId: string; configId: string; value: string } { +) { const record = asRecord(session); if (!record) throw new Error(`${options.provider} ACP session did not return session metadata.`); - const sessionId = typeof record?.sessionId === "string" ? record.sessionId : undefined; + const sessionId = isString(record?.sessionId) ? record.sessionId : undefined; if (!sessionId) throw new Error(`${options.provider} ACP session did not return a session id.`); const response = asRecord(record.newSessionResponse); @@ -306,7 +362,7 @@ function resolveAcpSelectConfigUpdate( return { sessionId, configId, value: options.value }; } -function flattenAcpSelectValues(option: Record): string[] { +function flattenAcpSelectValues(option: ProviderRecord): string[] { const values: string[] = []; for (const item of readArray(option, "options") ?? []) { const record = asRecord(item); @@ -346,7 +402,7 @@ class PiRpcLocalAgentAdapter implements LocalAgentAdapter { }); assertPipedChild(child); const rpc = new JsonLineRpc(child); - const events: unknown[] = []; + const events: ProviderValue[] = []; rpc.onEvent((event) => events.push(event)); try { const state = await rpc.request({ type: "get_state" }); @@ -392,10 +448,10 @@ export function piCommandEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv class JsonLineRpc { private readonly pending = new Map void; + resolve: (value: ProviderValue) => void; reject: (error: Error) => void; }>(); - private readonly eventSubscribers = new Set<(event: unknown) => void>(); + private readonly eventSubscribers = new Set<(event: ProviderValue) => void>(); private buffer = ""; private nextId = 1; private stderr = ""; @@ -411,7 +467,7 @@ class JsonLineRpc { }); } - request(command: Record): Promise { + request(command: ProviderRequest): Promise { if (this.fatalError) { return Promise.reject(this.fatalError); } @@ -423,12 +479,12 @@ class JsonLineRpc { }); } - onEvent(callback: (event: unknown) => void): () => void { + onEvent(callback: (event: ProviderValue) => void): () => void { this.eventSubscribers.add(callback); return () => this.eventSubscribers.delete(callback); } - waitForEvent(predicate: (event: unknown) => boolean, timeoutMs: number): Promise { + waitForEvent(predicate: (event: ProviderValue) => boolean, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { unsubscribe(); @@ -451,9 +507,10 @@ class JsonLineRpc { const line = this.buffer.slice(0, newline).trim(); this.buffer = this.buffer.slice(newline + 1); if (!line) continue; - let message: Record; + let message: ProviderRecord; try { - message = JSON.parse(line) as Record; + // SAFETY: the RPC transport emits one JSON object per line. + message = JSON.parse(line) as ProviderRecord; } catch { this.stderr += `${line}\n`; this.failAll(new Error(`Pi RPC emitted malformed JSON on stdout: ${line}`)); @@ -464,7 +521,7 @@ class JsonLineRpc { continue; } - const id = typeof message.id === "string" ? message.id : undefined; + const id = isString(message.id) ? message.id : undefined; if (!id) continue; const pending = this.pending.get(id); if (!pending) continue; @@ -486,68 +543,54 @@ class JsonLineRpc { } } -async function createOpencodeSession(client: unknown, input: LocalAgentRunInput): Promise { - const sessionClient = client as { - session: { - create(parameters?: unknown, options?: unknown): Promise; - }; - }; +async function createOpencodeSession(client: OpenCodeClient, input: LocalAgentRunInput): Promise { + const sessionClient = client; const result = await sessionClient.session.create({ directory: input.workspace, location: { directory: input.workspace }, - ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), + model: input.model ? parseOpencodeModel(input.model) : undefined, }, { throwOnError: true }); const id = readNestedString(result, ["id"]) ?? readNestedString(result, ["data", "id"]) ?? readNestedString(result, ["session", "id"]) ?? readNestedString(result, ["data", "session", "id"]); - if (typeof id !== "string") { + if (!id) { throw new Error("OpenCode did not return a session id."); } return id; } async function promptOpencodeSession( - client: unknown, + client: OpenCodeClient, sessionId: string, input: LocalAgentRunInput, -): Promise { - const session = (client as { - session: { - prompt(parameters?: unknown, options?: unknown): Promise; - }; - }).session; +): Promise { + const session = client.session; const promptInput = { sessionID: sessionId, directory: input.workspace, prompt: { parts: [{ type: "text", text: input.prompt }] }, parts: [{ type: "text", text: input.prompt }], - ...(input.model ? { model: parseOpencodeModel(input.model) } : {}), - ...(input.thinking ? { variant: input.thinking } : {}), + model: input.model ? parseOpencodeModel(input.model) : undefined, + variant: input.thinking, }; return session.prompt(promptInput, { throwOnError: true }); } -async function waitForOpencodeSession(client: unknown, sessionId: string): Promise { - const session = (client as { - session?: { wait?: (parameters?: unknown, options?: unknown) => Promise }; - }).session; +async function waitForOpencodeSession(client: OpenCodeClient, sessionId: string): Promise { + const session = client.session; if (!session?.wait) return; await session.wait({ sessionID: sessionId }, { throwOnError: true }); } -async function readOpencodeMessages(client: unknown, sessionId: string): Promise { - const session = (client as { - session?: { - messages?: (parameters?: unknown, options?: unknown) => Promise; - }; - }).session; +async function readOpencodeMessages(client: OpenCodeClient, sessionId: string): Promise { + const session = client.session; if (!session?.messages) return undefined; return session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); } -function parseOpencodeModel(model: string): { providerID: string; modelID: string } { +function parseOpencodeModel(model: string) { const separator = model.indexOf("/"); if (separator === -1) return { providerID: "opencode", modelID: model }; return { @@ -556,7 +599,12 @@ function parseOpencodeModel(model: string): { providerID: string; modelID: strin }; } -export function extractLocalAgentResponseText(value: unknown): string { +function decodeProviderResponse(value: T): ProviderValue { + const serialized = JSON.stringify(value); + return serialized === undefined ? undefined : JSON.parse(serialized); +} + +export function extractLocalAgentResponseText(value: ProviderValue): string { return extractOpenCodeFinalResponse(value) || extractPiFinalResponse(value); } @@ -566,14 +614,14 @@ function assertPipedChild(child: ReturnType): asserts child is Chi } } -export function extractOpenCodeFinalResponse(value: unknown): string { +export function extractOpenCodeFinalResponse(value: ProviderValue): string { const root = unwrapProviderPayload(value); const messages = Array.isArray(root) ? root : readArray(root, "messages"); if (messages) return extractLastOpenCodeAssistantMessageText(messages); return extractOpenCodeAssistantMessageText(root); } -export function extractPiFinalResponse(value: unknown): string { +export function extractPiFinalResponse(value: ProviderValue): string { const root = unwrapProviderPayload(value); const messages = Array.isArray(root) ? root : readArray(root, "messages"); if (!messages) return ""; @@ -587,21 +635,21 @@ export function extractPiFinalResponse(value: unknown): string { return ""; } -export function extractPiStreamingText(events: unknown[]): string { +export function extractPiStreamingText(events: ProviderValue[]): string { return events .map((event) => { const record = asRecord(event); if (!record || record.type !== "message_update") return ""; const update = asRecord(record.assistantMessageEvent); if (!update || update.type !== "text_delta") return ""; - return typeof update.delta === "string" ? update.delta : ""; + return isString(update.delta) ? update.delta : ""; }) .filter(Boolean) .join("") .trim(); } -export function extractPiProviderError(value: unknown): string { +export function extractPiProviderError(value: ProviderValue): string { const root = unwrapProviderPayload(value); if (Array.isArray(root)) { for (let index = root.length - 1; index >= 0; index -= 1) { @@ -618,16 +666,16 @@ export function extractPiProviderError(value: unknown): string { const record = asRecord(message); if (!record) return ""; const error = record.errorMessage ?? record.error; - return typeof error === "string" ? error.trim() : ""; + return isString(error) ? error.trim() : ""; } -function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { +function extractLastOpenCodeAssistantMessageText(messages: ProviderValue[]): string { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = asRecord(messages[index]); if (!message) continue; const info = asRecord(message.info); - const role = typeof info?.role === "string" ? info.role : message.role; - const type = typeof message.type === "string" ? message.type : undefined; + const role = isString(info?.role) ? info.role : message.role; + const type = isString(message.type) ? message.type : undefined; if (role !== "assistant" && type !== "assistant") continue; const text = extractOpenCodeAssistantMessageText(message); if (text) return text; @@ -635,7 +683,7 @@ function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { return ""; } -function extractOpenCodeAssistantMessageText(value: unknown): string { +function extractOpenCodeAssistantMessageText(value: ProviderValue): string { const message = asRecord(value); if (!message) return ""; @@ -645,7 +693,7 @@ function extractOpenCodeAssistantMessageText(value: unknown): string { .map((part) => { const partRecord = asRecord(part); if (!partRecord || partRecord.type !== "text") return ""; - return typeof partRecord.text === "string" ? partRecord.text : ""; + return isString(partRecord.text) ? partRecord.text : ""; }) .filter(Boolean) .join(""); @@ -658,7 +706,7 @@ function extractOpenCodeAssistantMessageText(value: unknown): string { .map((part) => { const partRecord = asRecord(part); if (!partRecord || partRecord.type !== "text") return ""; - return typeof partRecord.text === "string" ? partRecord.text : ""; + return isString(partRecord.text) ? partRecord.text : ""; }) .filter(Boolean) .join(""); @@ -669,51 +717,52 @@ function extractOpenCodeAssistantMessageText(value: unknown): string { return stringifyStructuredAssistantMessage(info.structured); } -function extractPiAssistantMessageText(message: Record): string { +function extractPiAssistantMessageText(message: ProviderRecord): string { const content = message.content; if (!Array.isArray(content)) return ""; return content .map((part) => { const partRecord = asRecord(part); if (!partRecord || partRecord.type !== "text") return ""; - return typeof partRecord.text === "string" ? partRecord.text : ""; + return isString(partRecord.text) ? partRecord.text : ""; }) .filter(Boolean) .join("\n\n") .trim(); } -function stringifyStructuredAssistantMessage(value: unknown): string { +function stringifyStructuredAssistantMessage(value: ProviderValue): string { if (value === undefined || value === null) return ""; - if (typeof value === "string") return value.trim(); + if (isString(value)) return value.trim(); return JSON.stringify(value); } -function unwrapProviderPayload(value: unknown): unknown { +function unwrapProviderPayload(value: ProviderValue): ProviderValue { const record = asRecord(value); if (!record) return value; return record.data ?? record.result ?? value; } -function readArray(record: unknown, key: string): unknown[] | undefined { +function readArray(record: ProviderValue, key: string): ProviderValue[] | undefined { const value = asRecord(record)?.[key]; return Array.isArray(value) ? value : undefined; } -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; - return value as Record; +function asRecord(value: ProviderValue): ProviderRecord | undefined { + if (!isObject(value)) return undefined; + // SAFETY: provider payload records are JSON-shaped objects and are accessed only by optional keys. + return value as ProviderRecord; } -function readNestedString(value: unknown, path: string[]): string | undefined { - let current: unknown = value; +function readNestedString(value: ProviderValue, path: string[]): string | undefined { + let current: ProviderValue = value; for (const key of path) { current = asRecord(current)?.[key]; } - return typeof current === "string" ? current : undefined; + return isString(current) ? current : undefined; } -function errorMessage(error: unknown): string { +function errorMessage(error: T): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 747f304fa..63639722a 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -134,7 +134,7 @@ function executableExists(command: string, env: NodeJS.ProcessEnv): boolean { windowsHide: true, timeout: 5_000, }); - const code = typeof result.error === "object" && result.error && "code" in result.error + const code = result.error instanceof Error && "code" in result.error ? result.error.code : undefined; return code !== "ENOENT"; diff --git a/src/local-agent-path.ts b/src/local-agent-path.ts index c8ff2935f..2b195676f 100644 --- a/src/local-agent-path.ts +++ b/src/local-agent-path.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { delimiter, resolve, sep } from "node:path"; +import type { JsonObject } from "./value-types.js"; export function removeDevspaceNodeModulesBinFromPath(pathValue: string): string { return pathValue @@ -18,7 +19,8 @@ function isDevspaceNodeModulesBin(pathEntry: string): boolean { if (!existsSync(packageJson)) return false; try { - const packageInfo = JSON.parse(readFileSync(packageJson, "utf8")) as { name?: unknown }; + // SAFETY: package.json is parsed from the package root and only the JSON object shape is consumed. + const packageInfo = JSON.parse(readFileSync(packageJson, "utf8")) as JsonObject; return packageInfo.name === "@waishnav/devspace"; } catch { return false; diff --git a/src/local-agent-profiles.ts b/src/local-agent-profiles.ts index a7fa0d876..85a236c5d 100644 --- a/src/local-agent-profiles.ts +++ b/src/local-agent-profiles.ts @@ -3,6 +3,7 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import type { ServerConfig } from "./config.js"; +import { isObject, isString, type JsonObject } from "./value-types.js"; export type LocalAgentProvider = "codex" | "claude" | "opencode" | "pi" | "cursor" | "copilot"; @@ -35,13 +36,11 @@ export interface LocalAgentProfileSummary { } interface ParsedFrontmatter { - frontmatter: Record; + frontmatter: JsonObject; body: string; } const FRONTMATTER_DELIMITER = "---"; -const PROVIDERS = new Set(LOCAL_AGENT_PROVIDERS); - export async function loadLocalAgentProfiles( config: ServerConfig, workspaceRoot: string, @@ -125,7 +124,7 @@ function parseFrontmatter(content: string, filePath: string): ParsedFrontmatter }; } -function parseProfileYaml(source: string, filePath: string): Record { +function parseProfileYaml(source: string, filePath: string): JsonObject { let parsed: unknown; try { parsed = parseYaml(source) ?? {}; @@ -133,15 +132,16 @@ function parseProfileYaml(source: string, filePath: string): Record; + // SAFETY: yaml mappings are plain objects after the object-shape check above. + return parsed as JsonObject; } function profileFromFrontmatter( - frontmatter: Record, + frontmatter: JsonObject, body: string, filePath: string, ): LocalAgentProfile { @@ -164,30 +164,31 @@ function profileFromFrontmatter( }; } -function readProvider(frontmatter: Record, filePath: string): LocalAgentProvider { +function readProvider(frontmatter: JsonObject, filePath: string): LocalAgentProvider { const provider = readString(frontmatter, "provider"); if (!provider) { throw new Error(`Subagent profile is missing provider: ${filePath}`); } - if (!PROVIDERS.has(provider as LocalAgentProvider)) { + const providerName = LOCAL_AGENT_PROVIDERS.find((candidate) => candidate === provider); + if (!providerName) { throw new Error( `Subagent profile provider must be codex, claude, opencode, pi, cursor, or copilot: ${filePath}`, ); } - return provider as LocalAgentProvider; + return providerName; } export function isLocalAgentProvider(value: string): value is LocalAgentProvider { - return PROVIDERS.has(value as LocalAgentProvider); + return LOCAL_AGENT_PROVIDERS.some((candidate) => candidate === value); } -function readString(frontmatter: Record, key: string): string | undefined { +function readString(frontmatter: JsonObject, key: string): string | undefined { const value = frontmatter[key]; - if (typeof value !== "string") return undefined; + if (!isString(value)) return undefined; const trimmed = value.trim(); return trimmed || undefined; } -function errorMessage(error: unknown): string { +function errorMessage(error: T): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 54130c2e2..92b4a2193 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -60,6 +60,7 @@ function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { sandboxMode: sandboxModeFor(input.writeMode), approvalPolicy: "never", model: input.model, + // SAFETY: the configured thinking value is passed through to the Codex SDK, whose accepted values are ModelReasoningEffort. modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, }; } @@ -98,5 +99,6 @@ export async function createCodexSdkLocalAgentRuntime( async function defaultCodexFactory(): Promise { const module = await import("@openai/codex-sdk"); + // SAFETY: the dynamically imported SDK exposes the Codex constructor required by CodexFactory. return (options) => new module.Codex(options) as Codex; } diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index a850ca9f6..437e360df 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -61,6 +61,7 @@ export class LocalAgentStore { list(scope: LocalAgentListScope = {}): LocalAgentRecord[] { let rows: LocalAgentRow[]; if (scope.workspaceId) { + // SAFETY: the selected columns match LocalAgentRow in the local_agent_sessions migration. rows = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -69,6 +70,7 @@ export class LocalAgentStore { ) .all(scope.workspaceId) as LocalAgentRow[]; } else if (scope.workspaceRoot) { + // SAFETY: the selected columns match LocalAgentRow in the local_agent_sessions migration. rows = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -77,6 +79,7 @@ export class LocalAgentStore { ) .all(resolve(scope.workspaceRoot)) as LocalAgentRow[]; } else { + // SAFETY: the selected columns match LocalAgentRow in the local_agent_sessions migration. rows = this.database.sqlite .prepare("select * from local_agent_sessions order by updated_at desc") .all() as LocalAgentRow[]; @@ -132,6 +135,7 @@ export class LocalAgentStore { } get(idOrPrefix: string): LocalAgentRecord | undefined { + // SAFETY: the selected columns match LocalAgentRow in the local_agent_sessions migration. const exact = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -141,6 +145,7 @@ export class LocalAgentStore { .get(idOrPrefix, idOrPrefix) as LocalAgentRow | undefined; if (exact) return rowToLocalAgentRecord(exact); + // SAFETY: the selected columns match LocalAgentRow in the local_agent_sessions migration. const matches = this.database.sqlite .prepare( `select * from local_agent_sessions @@ -201,6 +206,7 @@ export class LocalAgentStore { } private getById(id: string): LocalAgentRecord | undefined { + // SAFETY: the selected columns match LocalAgentRow in the local_agent_sessions migration. const row = this.database.sqlite .prepare("select * from local_agent_sessions where id = ?") .get(id) as LocalAgentRow | undefined; diff --git a/src/logger.ts b/src/logger.ts index c183ff64c..97271cd8a 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,5 @@ import type { Request } from "express"; +import { isString } from "./value-types.js"; export type LogLevel = "silent" | "error" | "warn" | "info" | "debug"; export type LogFormat = "json" | "pretty"; @@ -13,15 +14,23 @@ export interface LoggingConfig { trustProxy: boolean; } -type LogFields = Record; - -const LEVEL_WEIGHT: Record = { +type LogFieldValue = + | string + | number + | boolean + | null + | undefined + | LogFieldValue[] + | { [key: string]: LogFieldValue }; +type LogFields = { [key: string]: LogFieldValue }; + +const LEVEL_WEIGHT = { silent: 0, error: 1, warn: 2, info: 3, debug: 4, -}; +} satisfies Record; export function shouldLog(config: LoggingConfig, level: Exclude): boolean { return LEVEL_WEIGHT[config.level] >= LEVEL_WEIGHT[level]; @@ -93,7 +102,7 @@ function formatPretty(entry: LogFields): string { return rest ? `${ts} ${level} ${event} ${rest}` : `${ts} ${level} ${event}`; } -function formatPrettyValue(value: unknown): string { - if (typeof value === "string") return JSON.stringify(value); +function formatPrettyValue(value: LogFieldValue): string { + if (isString(value)) return JSON.stringify(value); return JSON.stringify(value); } diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index e65037884..ce49ed451 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -319,7 +319,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { function authorizationFormFields( client: OAuthClientInformationFull, params: AuthorizationParams, -): Record { +){ return { response_type: "code", client_id: client.client_id, diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e47f81216..9d524eb56 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -86,10 +86,12 @@ function testPersistenceAndTokenHashing(stateDir: string): void { const database = openDatabase(stateDir); try { + // SAFETY: each query selects the token_hash column from the store's token tables. const accessHashes = database.sqlite .prepare("select token_hash from oauth_access_tokens") .pluck() .all() as string[]; + // SAFETY: each query selects the token_hash column from the store's token tables. const refreshHashes = database.sqlite .prepare("select token_hash from oauth_refresh_tokens") .pluck() diff --git a/src/oauth-store.ts b/src/oauth-store.ts index 2567a40e1..642c5f39a 100644 --- a/src/oauth-store.ts +++ b/src/oauth-store.ts @@ -46,10 +46,12 @@ export class SqliteOAuthStore { } getClient(clientId: string): OAuthClientInformationFull | undefined { + // SAFETY: the query selects the single client_json column defined by the oauth_clients migration. const row = this.database.sqlite .prepare("select client_json from oauth_clients where client_id = ?") .get(clientId) as { client_json: string } | undefined; + // SAFETY: registered clients are serialized from OAuthClientInformationFull before insertion. return row ? (JSON.parse(row.client_json) as OAuthClientInformationFull) : undefined; } @@ -99,6 +101,7 @@ export class SqliteOAuthStore { } getAccessToken(tokenHash: string): PersistedAccessTokenRecord | undefined { + // SAFETY: the query columns match the access-token row shape below. const row = this.database.sqlite .prepare( "select client_id, scopes_json, expires_at, resource from oauth_access_tokens where token_hash = ?", @@ -157,6 +160,7 @@ export class SqliteOAuthStore { } getRefreshToken(tokenHash: string): PersistedRefreshTokenRecord | undefined { + // SAFETY: the query columns match the refresh-token row shape below. const row = this.database.sqlite .prepare( "select client_id, scopes_json, expires_at, resource from oauth_refresh_tokens where token_hash = ?", @@ -212,6 +216,7 @@ function rowToAccessTokenRecord(row: { }): PersistedAccessTokenRecord { return { clientId: row.client_id, + // SAFETY: scopes_json is written from the string[] scopes field by this store. scopes: JSON.parse(row.scopes_json) as string[], expiresAt: row.expires_at, resource: row.resource ?? undefined, @@ -226,6 +231,7 @@ function rowToRefreshTokenRecord(row: { }): PersistedRefreshTokenRecord { return { clientId: row.client_id, + // SAFETY: scopes_json is written from the string[] scopes field by this store. scopes: JSON.parse(row.scopes_json) as string[], expiresAt: row.expires_at, resource: row.resource ?? undefined, diff --git a/src/pi-tools.ts b/src/pi-tools.ts index 238b9c547..7f7fbe9ec 100644 --- a/src/pi-tools.ts +++ b/src/pi-tools.ts @@ -45,7 +45,7 @@ function toMcpContent(result: AgentToolResult): McpContent[] { }); } -function formatToolError(error: unknown): McpContent[] { +function formatToolError(error: T): McpContent[] { const message = error instanceof Error ? error.message : String(error); return [{ type: "text", text: message }]; } @@ -53,7 +53,7 @@ function formatToolError(error: unknown): McpContent[] { async function runTool( execute: (input: TInput) => Promise>, input: TInput, - context: ToolContext, + _context: ToolContext, ): Promise> { try { const result = await execute(input); diff --git a/src/process-platform.ts b/src/process-platform.ts index 905d4d731..bb1fcca93 100644 --- a/src/process-platform.ts +++ b/src/process-platform.ts @@ -69,6 +69,7 @@ export function terminateProcessTree( runtime.killGroup(child.pid, signal); return; } catch (error) { + // SAFETY: killGroup only throws Node system errors, whose code identifies the missing process case. if ((error as NodeJS.ErrnoException).code === "ESRCH") return; } } diff --git a/src/process-sessions.test.ts b/src/process-sessions.test.ts index b050e7903..315cadaf4 100644 --- a/src/process-sessions.test.ts +++ b/src/process-sessions.test.ts @@ -66,7 +66,7 @@ const background = await manager.start({ }); assert.equal(background.running, true); assert.ok(background.sessionId); -assert.equal(typeof background.sessionId, "number"); +assert.ok(Number.isInteger(background.sessionId)); await assert.rejects( manager.write({ @@ -94,7 +94,7 @@ const interactive = await manager.start({ }); assert.equal(interactive.running, true); assert.ok(interactive.sessionId); -assert.equal(typeof interactive.sessionId, "number"); +assert.ok(Number.isInteger(interactive.sessionId)); const inputResult = await manager.write({ workspaceId: "workspace-a", diff --git a/src/process-sessions.ts b/src/process-sessions.ts index f414df193..67cb65ce5 100644 --- a/src/process-sessions.ts +++ b/src/process-sessions.ts @@ -71,6 +71,8 @@ interface ProcessSessionManagerOptions { completedSessionTtlMs?: number; } +type ProcessEnvironment = NodeJS.ProcessEnv; + function boundedInteger(value: number | undefined, fallback: number, maximum: number): number { if (value === undefined) return fallback; if (!Number.isFinite(value) || value < 0) { @@ -90,8 +92,8 @@ function terminalSize(value: number | undefined, fallback: number): number { function processEnvironment(input?: { workspaceId?: string; workspaceRoot?: string; -}): Record { - return { +}) { + const environment: ProcessEnvironment = { ...Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ), @@ -103,9 +105,10 @@ function processEnvironment(input?: { CODEX_CI: "1", LANG: process.env.LANG ?? "C.UTF-8", LC_ALL: process.env.LC_ALL ?? "C.UTF-8", - ...(input?.workspaceId ? { DEVSPACE_WORKSPACE_ID: input.workspaceId } : {}), - ...(input?.workspaceRoot ? { DEVSPACE_WORKSPACE_ROOT: input.workspaceRoot } : {}), }; + if (input?.workspaceId) environment.DEVSPACE_WORKSPACE_ID = input.workspaceId; + if (input?.workspaceRoot) environment.DEVSPACE_WORKSPACE_ROOT = input.workspaceRoot; + return environment; } function codePointLength(value: string): number { @@ -127,7 +130,7 @@ function takeTail(value: string, count: number): string { return characters.slice(Math.max(0, characters.length - count)).join(""); } -function splitBudget(maxCharacters: number): { head: number; tail: number } { +function splitBudget(maxCharacters: number) { return { head: Math.ceil(maxCharacters / 2), tail: Math.floor(maxCharacters / 2), @@ -176,7 +179,7 @@ export class HeadTailBuffer { return this.totalCharacters > 0; } - drain(maxCharacters: number): { output: string; truncated: boolean } { + drain(maxCharacters: number) { if (!Number.isInteger(maxCharacters) || maxCharacters < 1) { throw new Error("Output limit must be a positive integer."); } @@ -197,7 +200,7 @@ export class HeadTailBuffer { } } -function truncateOutput(output: string, maxCharacters: number): { output: string; truncated: boolean } { +function truncateOutput(output: string, maxCharacters: number) { const outputCharacters = codePointLength(output); if (outputCharacters <= maxCharacters) return { output, truncated: false }; @@ -358,20 +361,16 @@ export class ProcessSessionManager { const shell = resolveShellCommand(input.command); let pty: import("node-pty").IPty; - try { - pty = nodePty.spawn(shell.executable, shell.args, { - cwd: input.cwd, - env: processEnvironment({ - workspaceId: input.workspaceId, - workspaceRoot: input.workspaceRoot, - }), - name: "xterm-256color", - cols: session.columns, - rows: session.rows, - }); - } catch (error) { - throw error; - } + pty = nodePty.spawn(shell.executable, shell.args, { + cwd: input.cwd, + env: processEnvironment({ + workspaceId: input.workspaceId, + workspaceRoot: input.workspaceRoot, + }), + name: "xterm-256color", + cols: session.columns, + rows: session.rows, + }); session.process = { write: (data) => pty.write(data), diff --git a/src/request-meta.ts b/src/request-meta.ts index 5b8cb1ead..1c01002a9 100644 --- a/src/request-meta.ts +++ b/src/request-meta.ts @@ -1,14 +1,12 @@ -function metadataString( - meta: unknown, - key: string, -): string | undefined { - if (typeof meta !== "object" || meta === null) return undefined; - const value = (meta as Record)[key]; - return typeof value === "string" && value.length > 0 ? value : undefined; +import { isString, type JsonObject } from "./value-types.js"; + +function metadataString(meta: JsonObject | undefined): string | undefined { + const value = meta?.["openai/session"]; + return isString(value) && value.length > 0 ? value : undefined; } export function openAiConversationScopeId( - meta: unknown, + meta: JsonObject | undefined, ): string | undefined { - return metadataString(meta, "openai/session"); + return metadataString(meta); } diff --git a/src/server.test.ts b/src/server.test.ts index 73eaf03b9..1141d8ce2 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -13,6 +13,7 @@ import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; +import { isObject, isString, type JsonObject } from "./value-types.js"; const execFileAsync = promisify(execFile); @@ -23,7 +24,7 @@ test("open_workspace keeps lifecycle flags out of model output and preserves com const tools = await context.client.listTools(); const openTool = tools.tools.find((tool) => tool.name === "open_workspace"); - const outputProperties = (openTool?.outputSchema as { properties?: Record } | undefined)?.properties; + const outputProperties = openTool?.outputSchema?.properties; assert.equal(outputProperties && "workspaceReused" in outputProperties, false); assert.equal(outputProperties && "includeBootstrapContext" in outputProperties, false); @@ -257,37 +258,37 @@ async function callOpen( conversationScopeId?: string, mode?: "checkout" | "worktree", ): Promise>> { - const params = { + const toolArguments = { path, mode } satisfies { path: string; mode?: "checkout" | "worktree" }; + const params: Parameters[0] = { name: "open_workspace", - arguments: { - path, - ...(mode ? { mode } : {}), - }, - ...(conversationScopeId - ? { _meta: { "openai/session": conversationScopeId } } - : {}), - } as Parameters[0]; + arguments: toolArguments, + }; + if (conversationScopeId) { + params._meta = { "openai/session": conversationScopeId }; + } return client.callTool(params); } -function structuredContent(result: Awaited>): Record { +function structuredContent(result: Awaited>): JsonObject { assert.ok(result.structuredContent); - return result.structuredContent as Record; + // SAFETY: the open_workspace response schema is the source of this structured content. + return result.structuredContent as JsonObject; } function responseText(result: Awaited>): string { - const content = (result as { content?: unknown }).content; + const content = result.content; assert.ok(Array.isArray(content)); - const first = content[0] as { type?: unknown; text?: unknown } | undefined; - assert.equal(first?.type, "text"); - assert.equal(typeof first?.text, "string"); - return first?.text as string; + const first = content[0]; + assert.ok(first && first.type === "text"); + assert.ok(isString(first.text)); + return first.text; } -function responseCard(result: Awaited>): Record { +function responseCard(result: Awaited>): JsonObject { const metadata = result._meta; - assert.ok(metadata && typeof metadata === "object"); - const card = (metadata as Record).card; - assert.ok(card && typeof card === "object"); - return card as Record; + assert.ok(isObject(metadata)); + const card = "card" in metadata ? metadata.card : undefined; + assert.ok(isObject(card)); + // SAFETY: the response card is produced by the server's structured card contract. + return card as JsonObject; } diff --git a/src/server.ts b/src/server.ts index 840594ab7..c34e50082 100644 --- a/src/server.ts +++ b/src/server.ts @@ -50,7 +50,7 @@ import { } from "./mcp-sessions.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; -import { openAiConversationScopeId } from "./request-meta.js"; +import { isString, type JsonObject } from "./value-types.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -122,14 +122,14 @@ type ToolWidgetKind = | "shell" | "show_changes"; -interface ToolDefinitionMeta extends Record { +interface ToolDefinitionMeta extends JsonObject { ui: { resourceUri: string; visibility: ["model"]; }; } -type EmptyToolDefinitionMeta = Record & { +type EmptyToolDefinitionMeta = JsonObject & { "ui/resourceUri"?: string; }; @@ -236,7 +236,9 @@ function formatUnavailableAgentProvider(provider: LocalAgentProviderAvailability return `${provider.name} (${provider.reason ?? "unavailable"})`; } -function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { +type SchemaFields = Parameters[0]; + +function resultOutputSchema(extra: SchemaFields = {}) { return { result: z .string() @@ -278,20 +280,6 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); -const reviewFileOutputSchema = z.object({ - path: z.string(), - previousPath: z.string().optional(), - type: z.enum(["change", "rename-pure", "rename-changed", "new", "deleted"]), - additions: z.number(), - removals: z.number(), -}); - -const reviewSummaryOutputSchema = z.object({ - files: z.number(), - additions: z.number(), - removals: z.number(), -}); - function sendJsonRpcError( res: Response, status: number, @@ -305,7 +293,7 @@ function sendJsonRpcError( }); } -function requestLogFields(req: Request, config: ServerConfig): Record { +function requestLogFields(req: Request, config: ServerConfig) { return { ip: requestIp(req, config.logging.trustProxy), host: req.header("host"), @@ -359,10 +347,7 @@ function textBlock(text: string): ToolContent { return { type: "text", text }; } -function textSummary(content: ToolContent[]): { - lines: number; - characters: number; -} { +function textSummary(content: ToolContent[]) { const text = contentText(content); return { lines: text.length === 0 ? 0 : text.split("\n").length, @@ -424,6 +409,7 @@ function uiManifestUrl(): URL { } function readWorkspaceAppManifest(): WorkspaceAppManifest { + // SAFETY: the generated Vite manifest is read from the package's own dist directory. return JSON.parse(readFileSync(uiManifestUrl(), "utf8")) as WorkspaceAppManifest; } @@ -469,10 +455,7 @@ ${stylesheets} `; } -function appCsp(config: ServerConfig): { - resourceDomains: string[]; - connectDomains: string[]; -} { +function appCsp(config: ServerConfig) { const publicBaseUrl = config.publicBaseUrl.replace(/\/+$/, ""); return { resourceDomains: [publicBaseUrl], @@ -511,7 +494,7 @@ function processResult(snapshot: ProcessSnapshot): string { return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status; } -function processOutputSchema(): z.ZodRawShape { +function processOutputSchema() { return resultOutputSchema({ sessionId: z.number().optional(), running: z.boolean(), @@ -526,7 +509,7 @@ function processToolResponse( tool: "exec_command" | "write_stdin", workspaceId: string, snapshot: ProcessSnapshot, - summary: Record, + summary: JsonObject, ) { const result = processResult(snapshot); const content = [textBlock(result)]; @@ -809,7 +792,7 @@ export function createMcpServer( includeBootstrapContext, } = await workspaces.openWorkspace( { path, mode, baseRef }, - { conversationScopeId: openAiConversationScopeId(_meta) }, + { conversationScopeId: isString(_meta?.["openai/session"]) ? _meta["openai/session"] : undefined }, ); if (config.widgets === "changes") { await reviewCheckpoints.initializeWorkspace({ @@ -899,6 +882,25 @@ export function createMcpServer( durationMs: Math.round(performance.now() - startedAt), }); + const structuredContent = { + workspaceId: workspace.id, + root: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + instruction, + }; + if (includeBootstrapContext) { + Object.assign(structuredContent, { + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, + skillDiagnostics: workspace.skillDiagnostics, + }); + } + return { content: resultContent, _meta: { @@ -928,24 +930,7 @@ export function createMcpServer( }, }, }, - structuredContent: { - workspaceId: workspace.id, - root: workspace.root, - mode: workspace.mode, - sourceRoot: workspace.sourceRoot, - worktree: workspace.worktree, - ...(includeBootstrapContext - ? { - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, - } - : {}), - instruction, - }, + structuredContent, }; }, ); @@ -1676,7 +1661,7 @@ export function createServer( : Array.from(new Set([config.host, ...config.allowedHosts])); const app = createMcpExpressApp({ host: config.host, - ...(allowedHosts ? { allowedHosts } : {}), + allowedHosts, }); const transports = new McpSessionRegistry(); const mcpUrl = new URL("/mcp", config.publicBaseUrl); @@ -1784,12 +1769,12 @@ export function createServer( }); app.all("/mcp", async (req, res) => { - const requestId = res.locals.requestId as string | undefined; + const requestId: string | undefined = res.locals.requestId; const sessionId = req.header("mcp-session-id"); const initializeRequest = req.method === "POST" && isInitializeRequest(req.body); await new Promise((resolve, reject) => { - bearerAuth(req, res, (error?: unknown) => { + bearerAuth(req, res, (error?: Error | string) => { if (error) reject(error); else resolve(); }); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 3d2380830..6bcf003fd 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -24,6 +24,18 @@ export type ReviewFileType = | "new" | "deleted"; +import { isNumber, isObject } from "../value-types.js"; + +type CardValue = + | string + | number + | boolean + | null + | undefined + | CardValue[] + | { [key: string]: CardValue }; +type CardSummary = { [key: string]: CardValue }; + export interface ToolResultCard { tool: ToolName; workspaceId?: string; @@ -42,7 +54,7 @@ export interface ToolResultCard { managed?: boolean; }; status?: string; - summary?: Record; + summary?: CardSummary; files?: Array<{ path?: string; previousPath?: string; @@ -94,7 +106,7 @@ export interface ToolPayload { patch?: string; } -export function isToolName(value: unknown): value is ToolName { +export function isToolName(value: T): value is T & ToolName { return ( value === "open_workspace" || value === "show_changes" || @@ -139,8 +151,8 @@ export function isReviewTool(tool: ToolName): boolean { return tool === "show_changes"; } -export function isToolResultCard(value: unknown): value is Omit { - return Boolean(value && typeof value === "object"); +export function isToolResultCard(value: T): value is T & Omit { + return isObject(value); } export function payloadText(payload: ToolPayload | undefined): string { @@ -156,11 +168,11 @@ export function payloadText(payload: ToolPayload | undefined): string { } export function summaryNumber( - summary: Record | undefined, + summary: CardSummary | undefined, key: string, ): number | undefined { const value = summary?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return isNumber(value) && Number.isFinite(value) ? value : undefined; } export function isExpandableCard(card: ToolResultCard): boolean { diff --git a/src/ui/heavy-payload.tsx b/src/ui/heavy-payload.tsx index a61e6dacb..f2e10cc33 100644 --- a/src/ui/heavy-payload.tsx +++ b/src/ui/heavy-payload.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { createRoot } from "react-dom/client"; import { FileStream, getFiletypeFromFileName } from "@pierre/diffs"; import type { FileStreamOptions } from "@pierre/diffs"; import { PatchDiff } from "@pierre/diffs/react"; diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 022105d03..24a8a9843 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -60,10 +60,12 @@ const providerLogos = { opencode: new URL("./assets/provider-logos/opencode-dark.svg", import.meta.url).href, pi: new URL("./assets/provider-logos/pi-on-dark.svg", import.meta.url).href, } as const; +const providerNames = ["claude", "codex", "copilot", "cursor", "opencode", "pi"] as const; export function getProviderLogo(name: string): string | undefined { - const normalizedName = name.trim().toLowerCase() as keyof typeof providerLogos; - return providerLogos[normalizedName]; + const normalizedName = name.trim().toLowerCase(); + const providerName = providerNames.find((candidate) => candidate === normalizedName); + return providerName ? providerLogos[providerName] : undefined; } export function renderIcon(icon: ToolIcon, className = "icon-svg"): SVGElement { diff --git a/src/ui/patch-display.ts b/src/ui/patch-display.ts index ec1f7ad29..ed4bfc82b 100644 --- a/src/ui/patch-display.ts +++ b/src/ui/patch-display.ts @@ -22,13 +22,13 @@ export interface FileChangePathDisplay { title: string; } -const fileChangeLabels: Record, string> = { +const fileChangeLabels = { added: "Added", edited: "Edited", deleted: "Deleted", renamed: "Renamed", "renamed-edited": "Renamed and edited", -}; +} satisfies Record, string>; export function getPatchDisplayParts( card: Pick, diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 455e54726..a466be7db 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -10,6 +10,7 @@ import { type FileChangeKind, } from "./patch-display.js"; import { pierrePrettyScrollbarCss } from "./scrollbar.js"; +import { isNumber } from "../value-types.js"; type ThemeType = "light" | "dark"; @@ -51,7 +52,7 @@ function ReviewPayload({ const patch = card.payload?.patch; const themeType: ThemeType = hostContext?.theme === "light" ? "light" : "dark"; const files = useMemo(() => parseFiles(patch), [patch]); - const visibleFiles = typeof visibleFileCount === "number" + const visibleFiles = isNumber(visibleFileCount) ? files.slice(0, visibleFileCount) : files; const [openFiles, setOpenFiles] = useState(() => new Set()); diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index be64e0b00..bf631267b 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -12,6 +12,7 @@ import { getFileChangePathDisplay, getPatchDisplayParts, } from "./patch-display.js"; +import { isNumber, isString } from "../value-types.js"; export interface ToolDisplay { icon: ToolIcon; @@ -175,8 +176,8 @@ function singleFilePath(card: ToolResultCard): string | undefined { function searchLabel(card: ToolResultCard): string | undefined { const pattern = card.summary?.pattern; const scope = card.summary?.scope; - if (typeof pattern !== "string") return card.path; - return typeof scope === "string" && scope !== "." ? `${pattern} in ${scope}` : pattern; + if (!isString(pattern)) return card.path; + return isString(scope) && scope !== "." ? `${pattern} in ${scope}` : pattern; } function processTitle(card: ToolResultCard, subject: "command" | "process"): string { @@ -201,9 +202,9 @@ function processState(card: ToolResultCard): ToolDisplay["state"] { function processLabel(card: ToolResultCard): string | undefined { const command = card.summary?.command; - if (typeof command === "string") return command; + if (isString(command)) return command; const sessionId = card.summary?.sessionId; - if (typeof sessionId === "number" || typeof sessionId === "string") { + if (isNumber(sessionId) || isString(sessionId)) { return `Session ${String(sessionId)}`; } return card.path; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index eab9c501d..d667fc54f 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -26,6 +26,7 @@ import { getToolHeaderSummary, type ToolDisplay, } from "./tool-display.js"; +import { isObject, type JsonObject } from "../value-types.js"; import "./workspace-app.css"; interface MountedPayload { @@ -868,18 +869,23 @@ function renderWorkspaceChips(chips: WorkspaceChip[]): HTMLElement { } function toolNameFromMeta(result: CallToolResult): ToolName | undefined { - const meta = result._meta as Record | undefined; + // SAFETY: MCP metadata is a JSON object and this card only reads optional fields from it. + const meta = result._meta as JsonObject | undefined; const tool = meta?.tool; return isToolName(tool) ? tool : undefined; } function cardFromMeta(result: CallToolResult): Partial | undefined { - const meta = result._meta as Record | undefined; + // SAFETY: MCP metadata is a JSON object and this card only reads optional fields from it. + const meta = result._meta as JsonObject | undefined; const metaCard = meta?.card; - return metaCard && typeof metaCard === "object" ? metaCard : undefined; + if (!isObject(metaCard)) return undefined; + // SAFETY: the card metadata is validated by isToolResultCard before rendering. + return metaCard as Partial; } function getStructuredContent(result: CallToolResult): T | undefined { + // SAFETY: callers provide the schema-specific structured content type for this tool result. return result.structuredContent as T | undefined; } @@ -905,6 +911,7 @@ function element( if (options.ariaLabel !== undefined) node.setAttribute("aria-label", options.ariaLabel); if (options.ariaExpanded !== undefined) node.setAttribute("aria-expanded", options.ariaExpanded); if (options.disabled !== undefined && "disabled" in node) { + // SAFETY: the DOM property exists because the element was checked for disabled above. (node as HTMLButtonElement).disabled = options.disabled; } return node; diff --git a/src/user-config.ts b/src/user-config.ts index 5dd793ef8..612336d0a 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -119,6 +119,7 @@ export function resolveSubagentsFlag( function readJsonFile(filePath: string): T { try { + // SAFETY: callers select the config/auth owner type for the corresponding known JSON file. return JSON.parse(readFileSync(filePath, "utf8")) as T; } catch (error) { const reason = error instanceof Error ? error.message : String(error); @@ -126,6 +127,10 @@ function readJsonFile(filePath: string): T { } } -function writeJsonFile(filePath: string, value: unknown, mode: number): void { +function writeJsonFile( + filePath: string, + value: DevspaceUserConfig | DevspaceAuthConfig, + mode: number, +): void { writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", { mode }); } diff --git a/src/value-types.ts b/src/value-types.ts new file mode 100644 index 000000000..00455d675 --- /dev/null +++ b/src/value-types.ts @@ -0,0 +1,39 @@ +export interface JsonObject { + [key: string]: JsonValue | undefined; +} + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | JsonObject; + +export function isString(value: T): value is T & string { + return Object.prototype.toString.call(value) === "[object String]"; +} + +export function isNumber(value: T): value is T & number { + return Object.prototype.toString.call(value) === "[object Number]"; +} + +export function isBoolean(value: T): value is T & boolean { + return Object.prototype.toString.call(value) === "[object Boolean]"; +} + +export function isBigInt(value: T): value is T & bigint { + return Object.prototype.toString.call(value) === "[object BigInt]"; +} + +export function isFunction(value: T): value is T & ((...args: never[]) => void) { + return Object.prototype.toString.call(value) === "[object Function]"; +} + +export function isSymbol(value: T): value is T & symbol { + return Object.prototype.toString.call(value) === "[object Symbol]"; +} + +export function isObject(value: T): value is T & object { + return Object.prototype.toString.call(value) === "[object Object]"; +} diff --git a/src/workspace-conversation.test.ts b/src/workspace-conversation.test.ts index 5af9f991d..1478d4852 100644 --- a/src/workspace-conversation.test.ts +++ b/src/workspace-conversation.test.ts @@ -166,7 +166,7 @@ test("a failed first context load does not consume bootstrap", async (t) => { await restoreAgentsDirectory(agentsDir, backupDir); } - const successfulOpen = await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); + await registry.openWorkspace(project, { conversationScopeId: "chat-1" }); }); test("a context-loading failure preserves a valid checkout binding", async (t) => { @@ -368,9 +368,7 @@ test("unexpected filesystem errors are propagated without replacing the binding" const restoredRegistry = new WorkspaceRegistry(context.config, restoredStore); await assert.rejects( () => restoredRegistry.openWorkspace(context.project, { conversationScopeId: "chat-1" }), - (error: unknown) => - typeof error === "object" && - error !== null && + (error: Error & { code?: string }) => "code" in error && error.code === "ELOOP", ); diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 8584c1b7e..c08279775 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -78,7 +78,7 @@ test("worktree opens require Git and create an isolated managed workspace", asyn await assert.rejects( () => context.registry.openWorkspace({ path: context.root, mode: "worktree" }), - (error: unknown) => + (error: Error) => error instanceof GitWorktreeError && error.code === "GIT_REPOSITORY_NOT_FOUND", ); diff --git a/src/workspaces.ts b/src/workspaces.ts index 307626489..899b1688a 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -27,6 +27,7 @@ import { loadLocalAgentProfiles, type LocalAgentProfile, } from "./local-agent-profiles.js"; +import { isString } from "./value-types.js"; export interface LoadedAgentsFile { path: string; @@ -85,7 +86,7 @@ export interface OpenWorkspaceOptions { type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; - mkdir: (path: string, options: { recursive: true }) => Promise; + mkdir: (path: string, options: { recursive: true }) => Promise; }; export class WorkspaceRegistry { @@ -101,7 +102,7 @@ export class WorkspaceRegistry { input: string | OpenWorkspaceInput, openOptions: OpenWorkspaceOptions = {}, ): Promise { - const workspaceInput = typeof input === "string" ? { path: input } : input; + const workspaceInput = isString(input) ? { path: input } : input; const conversationScopeId = openOptions.conversationScopeId; if (!conversationScopeId || !this.store) { return this.openNewWorkspace(workspaceInput); @@ -579,6 +580,6 @@ async function walkWorkspace( } } -function isErrnoException(error: unknown): error is NodeJS.ErrnoException { +function isErrnoException(error: T): error is T & NodeJS.ErrnoException { return error instanceof Error && "code" in error; } diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 000000000..2b4ae2223 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,41 @@ +import { eslintCompatPlugin } from "@oxlint/plugins"; + +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-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-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 000000000..0d1185278 --- /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.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 000000000..ae7248d36 --- /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.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 000000000..2a6806c69 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,247 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from "../shared/dictionary-types.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 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); + }, + 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}\``, + ); + }, + 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.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 000000000..d6fb5b45c --- /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.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 000000000..29b990f33 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,126 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); +} + +/** 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) { + const aliases = new Map(); + + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToObject(member, shadowedAliases, visited), + ); + } + if ( + type.type !== "TSTypeReference" || + type.typeName.type !== "Identifier" || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) || + shadowedAliases.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, shadowedAliases, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames( + node, + context.sourceCode.visitorKeys, + ); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + 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-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 000000000..2cc30451b --- /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.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 000000000..cf630ecc0 --- /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.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 000000000..6a25c2475 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,67 @@ +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; +} + +/** 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" && + (!allowInTypeGuards || !isInsideTypeGuard(node)) + ) { + context.report({ node, messageId: "runtimeTypeof" }); + } + }, + }; + }, +}); 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 000000000..afc00dd41 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +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); +} + +/** 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)) 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.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 000000000..cdc6c2351 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === "TSParameterProperty") { + return parameterName(parameter.parameter, sourceText); + } + if (parameter.type === "AssignmentPattern") { + return parameterName(parameter.left, sourceText); + } + if (parameter.type === "RestElement") { + return parameterName(parameter.argument, sourceText); + } + return parameter.type === "Identifier" + ? parameter.name + : sourceText.replace(/\s*:\s*unknown\s*$/u, ""); +} + +/** 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`; 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 = parameterAnnotation(parameter); + if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue; + const name = parameterName(parameter, context.sourceCode.getText(parameter)); + if (name === "cause") 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.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 000000000..4b16d6ef3 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,115 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** 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) { + const aliases = new Map(); + + const resolvesToUnknown = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") { + return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited); + } + if (type.type === "TSUnionType") { + return type.types.some((member) => + resolvesToUnknown(member, shadowedAliases, visited), + ); + } + if ( + type.type === "TSTypeReference" && + type.typeName.type === "Identifier" && + (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike") + ) { + const value = type.typeArguments?.params[0]; + return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited); + } + const name = referencedAliasName(type); + if (name === null || visited.has(name) || shadowedAliases.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited); + }; + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys), + ) + ) { + return; + } + context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" }); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + }, + 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.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 000000000..3e328fdfc --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,70 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree } from "@oxlint/plugins"; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** 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) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === "TSUnknownKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if (declaration?.type === "TSTypeAliasDeclaration") { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: "unknownAlias", + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); 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 000000000..8c45eed27 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,134 @@ +import { defineRule } from "@oxlint/plugins"; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from "../shared/dictionary-types.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 && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + 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); + }, + 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.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 000000000..c5e07f7fc --- /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.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 000000000..f1a2ffcf9 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,62 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, SourceCode } from "@oxlint/plugins"; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +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 hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node; + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true; + } + if (commentOwnerKinds.has(current.type) || 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 `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.", + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; + context.report({ node, messageId: "missingSafetyComment" }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); 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 000000000..865170047 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,502 @@ +import type { ESTree } from "@oxlint/plugins"; + +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 aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +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): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === "ImportDeclaration") { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === "TSTypeAliasDeclaration") { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSInterfaceDeclaration") { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === "TSEnumDeclaration") { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === "ClassDeclaration" || + declaration?.type === "FunctionDeclaration") && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +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, 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 = environment.aliases.get(name); + if (alias === undefined || 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, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === "Record" && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || 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; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +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, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { 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 isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") { + return unwrapped.types.every((member) => + isBroadMappedKey(member, environment, substitutions), + ); + } + 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); + } + return name === "PropertyKey" && isBuiltIn(name, environment); +} + +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, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === "Record" && isBuiltIn(name, environment)) { + return { kind: "open dictionary" }; + } + const alias = environment.aliases.get(name); + if (alias === undefined || 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/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 000000000..7cdb18c91 --- /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 000000000..39bc218c3 --- /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; +}