diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..bdf4c5d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,21 @@ +{ + "ignorePatterns": [".claude/**", "node_modules/**", "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 2c76033..dac4249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,14 +5,46 @@ "packages": { "": { "name": "strata", + "dependencies": { + "@atcute/lexicons": "2.1.0" + }, "devDependencies": { "@cloudflare/workers-types": "5.20260829.1", + "@oxlint/plugins": "1.80.0", "@types/node": "22.20.1", + "oxlint": "1.80.0", "tsx": "4.23.12", "typescript": "5.9.3", "wrangler": "4.127.1" } }, + "node_modules/@atcute/lexicons": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.1.0.tgz", + "integrity": "sha512-cqbtPfFctoddrSFBt65xyD7st01d8rNntyinRfRhlRPGlqEg8FWacgc7piBw0SAs1SG+5BoV3d3mhzWfQHcByg==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.5", + "@atcute/util-text": "^1.3.4", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/uint8array": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.5.tgz", + "integrity": "sha512-1SFCXOtjE3ismP92CqzbOfYSwJmPPPFbj798wLXSpLv2hSd5OIaUrTh6EqRVR1VZ3ZhR+vaBmd3kxAN0wkw+gQ==", + "license": "0BSD" + }, + "node_modules/@atcute/util-text": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-1.3.4.tgz", + "integrity": "sha512-u2UAM7iSM09sQaSG9jtxWFSPgB8boVj50/BoyMvYnhVgGBu+nXIuAcdDUQCsZA44YZgTPPhN/b86JX+jH7SPzQ==", + "license": "0BSD", + "dependencies": { + "unicode-segmenter": "^0.17.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1152,6 +1184,342 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz", + "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz", + "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz", + "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz", + "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz", + "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz", + "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz", + "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz", + "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz", + "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz", + "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz", + "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz", + "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz", + "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz", + "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz", + "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz", + "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz", + "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz", + "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==", + "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.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz", + "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/plugins": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/plugins/-/plugins-1.80.0.tgz", + "integrity": "sha512-QRgH1XqQEYNHa4f1vvPQ5fAdNdncHGIUG1ZWLlGIZHky3qwCEeAKYitZNbZMtaXtAQAAFFTOwqUfzESvimqZNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1201,6 +1569,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -1294,6 +1668,12 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1337,6 +1717,55 @@ "node": ">=22.0.0" } }, + "node_modules/oxlint": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz", + "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==", + "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.80.0", + "@oxlint/binding-android-arm64": "1.80.0", + "@oxlint/binding-darwin-arm64": "1.80.0", + "@oxlint/binding-darwin-x64": "1.80.0", + "@oxlint/binding-freebsd-x64": "1.80.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", + "@oxlint/binding-linux-arm-musleabihf": "1.80.0", + "@oxlint/binding-linux-arm64-gnu": "1.80.0", + "@oxlint/binding-linux-arm64-musl": "1.80.0", + "@oxlint/binding-linux-ppc64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-musl": "1.80.0", + "@oxlint/binding-linux-s390x-gnu": "1.80.0", + "@oxlint/binding-linux-x64-gnu": "1.80.0", + "@oxlint/binding-linux-x64-musl": "1.80.0", + "@oxlint/binding-openharmony-arm64": "1.80.0", + "@oxlint/binding-win32-arm64-msvc": "1.80.0", + "@oxlint/binding-win32-ia32-msvc": "1.80.0", + "@oxlint/binding-win32-x64-msvc": "1.80.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -1490,6 +1919,12 @@ "pathe": "^2.0.3" } }, + "node_modules/unicode-segmenter": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.17.3.tgz", + "integrity": "sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==", + "license": "MIT" + }, "node_modules/workerd": { "version": "1.20260828.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260828.1.tgz", diff --git a/package.json b/package.json index 2f54ff2..4d037c9 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,20 @@ "typecheck": "tsc --noEmit", "test": "node --test --import tsx tests/*.test.ts", "deploy": "wrangler deploy", - "migrate": "wrangler d1 execute strata --remote --file schema.sql" + "migrate": "wrangler d1 execute strata --remote --file schema.sql", + "lint": "oxlint src tests", + "check": "tsc --noEmit && oxlint src tests && node --test --import tsx tests/*.test.ts" }, "devDependencies": { "@cloudflare/workers-types": "5.20260829.1", + "@oxlint/plugins": "1.80.0", "@types/node": "22.20.1", + "oxlint": "1.80.0", "tsx": "4.23.12", "typescript": "5.9.3", "wrangler": "4.127.1" + }, + "dependencies": { + "@atcute/lexicons": "2.1.0" } } diff --git a/src/index.ts b/src/index.ts index 339c4f9..e20f876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,20 @@ * collection index, and POSTs the result here. this worker only stores and * aggregates; it holds no schedule and never talks to the archive itself. */ -import { IngestParseError, namespaceOf, parseIngestBatch, type SegmentRecord } from "./ingest"; -import page from "./page.html"; +import { safeParse } from "@atcute/lexicons/validations"; + +import { IngestBatchSchema, checkIngestBatch, namespaceOf, type SegmentRecord } from "./ingest"; +import pageSource from "./page.html"; +import ui from "./ui.js"; + +/** the page names its script by content hash so an html/js cache skew on deploy cannot happen. */ +function contentHash(text: string): string { + let h = 2166136261; + for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619); + return (h >>> 0).toString(36); +} +const UI_VERSION = contentHash(ui); +const page = pageSource.replace('src="ui.js"', `src="ui.js?v=${UI_VERSION}"`); interface Env { readonly DB: D1Database; @@ -39,7 +51,10 @@ async function putArchive(request: Request, key: string, env: Env): Promise = {}): Response { +/** what this worker answers with: plain JSON built from our own row types (type aliases, so arrays of them fit). */ +type JsonValue = string | number | boolean | null | undefined | readonly JsonValue[] | { readonly [key: string]: JsonValue }; + +function json(body: JsonValue, status = 200, headers: Readonly> = {}): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*", ...headers }, @@ -97,27 +112,30 @@ async function ingest(request: Request, env: Env): Promise { if (!authorized(request, env)) return json({ error: "unauthorized" }, 401); const length = Number(request.headers.get("content-length") ?? "0"); if (!Number.isFinite(length) || length > MAX_BODY_BYTES) return json({ error: "body too large" }, 413); - let batch; + let body; try { - batch = parseIngestBatch(await request.json()); - } catch (err) { - if (err instanceof IngestParseError) return json({ error: err.message }, 400); + body = await request.json(); + } catch { return json({ error: "invalid json" }, 400); } + const parsed = safeParse(IngestBatchSchema, body); + if (!parsed.ok) return json({ error: parsed.message }, 400); + const problem = checkIngestBatch(parsed.value); + if (problem !== null) return json({ error: problem }, 400); const ingestedAt = Date.now(); - for (const segment of batch.segments) { + for (const segment of parsed.value.segments) { await env.DB.batch(segmentStatements(env.DB, segment, ingestedAt)); } - return json({ ingested: batch.segments.length }); + return json({ ingested: parsed.value.segments.length }); } -interface ProgressRow { +type ProgressRow = { readonly segments: number; readonly max_idx: number | null; readonly max_seq: number | null; readonly events: number | null; readonly updated_at: number | null; -} +}; async function progress(env: Env): Promise { const [row, kinds] = await env.DB.batch([ @@ -126,8 +144,9 @@ async function progress(env: Env): Promise { ), env.DB.prepare("SELECT COUNT(DISTINCT nsid) AS collections, COUNT(DISTINCT ns) AS namespaces FROM segment_collections"), ]); - // SAFETY: the two statements select exactly these columns. + // SAFETY: the first statement selects exactly ProgressRow's columns. const p = row?.results[0] as ProgressRow | undefined; + // SAFETY: the second statement selects exactly these two counts. const k = kinds?.results[0] as { collections: number; namespaces: number } | undefined; return json({ segments: p?.segments ?? 0, @@ -140,13 +159,13 @@ async function progress(env: Env): Promise { }); } -interface HeatRow { +type HeatRow = { readonly bucket: number; readonly key: string; readonly count: number; -} +}; -interface BucketRow { +type BucketRow = { readonly bucket: number; readonly first_idx: number; readonly last_idx: number; @@ -155,7 +174,7 @@ interface BucketRow { readonly min_witnessed_at: number; readonly max_witnessed_at: number; readonly event_count: number; -} +}; const MAX_TOP = 60; @@ -165,20 +184,22 @@ interface HeatQuery { readonly scope: { readonly kind: "namespaces" } | { readonly kind: "namespace"; readonly ns: string } | { readonly kind: "collections" }; } -function parseHeatQuery(url: URL): HeatQuery | string { +type HeatQueryResult = { readonly ok: true; readonly query: HeatQuery } | { readonly ok: false; readonly error: string }; + +function parseHeatQuery(url: URL): HeatQueryResult { const step = Number(url.searchParams.get("step") ?? "10"); const top = Number(url.searchParams.get("top") ?? "12"); - if (!Number.isSafeInteger(step) || step < 1) return "step must be a positive integer"; - if (!Number.isSafeInteger(top) || top < 1 || top > MAX_TOP) return `top must be 1..${MAX_TOP}`; + if (!Number.isSafeInteger(step) || step < 1) return { ok: false, error: "step must be a positive integer" }; + if (!Number.isSafeInteger(top) || top < 1 || top > MAX_TOP) return { ok: false, error: `top must be 1..${MAX_TOP}` }; const ns = url.searchParams.get("ns"); const group = url.searchParams.get("group") ?? "namespaces"; if (ns !== null) { - if (!/^[a-z0-9-]+\.[a-z0-9-]+$/i.test(ns)) return "ns must be an authority like fm.teal"; - return { step, top, scope: { kind: "namespace", ns } }; + if (!/^[a-z0-9-]+\.[a-z0-9-]+$/i.test(ns)) return { ok: false, error: "ns must be an authority like fm.teal" }; + return { ok: true, query: { step, top, scope: { kind: "namespace", ns } } }; } - if (group === "collections") return { step, top, scope: { kind: "collections" } }; - if (group === "namespaces") return { step, top, scope: { kind: "namespaces" } }; - return "group must be namespaces or collections"; + if (group === "collections") return { ok: true, query: { step, top, scope: { kind: "collections" } } }; + if (group === "namespaces") return { ok: true, query: { step, top, scope: { kind: "namespaces" } } }; + return { ok: false, error: "group must be namespaces or collections" }; } /** rows of the heat map: (bucket, key, count) where key is a namespace, or an nsid inside one namespace, or a @@ -239,15 +260,16 @@ function heatKeysStatement(db: D1Database, q: HeatQuery): D1PreparedStatement { } } -interface KeyRow { +type KeyRow = { readonly key: string; readonly count: number; readonly members: number; -} +}; async function heat(url: URL, env: Env): Promise { - const q = parseHeatQuery(url); - if (typeof q === "string") return json({ error: q }, 400); + const parsed = parseHeatQuery(url); + if (!parsed.ok) return json({ error: parsed.error }, 400); + const q = parsed.query; const progressRow = await env.DB.prepare("SELECT MAX(idx) AS max_idx FROM segments").first<{ max_idx: number | null }>(); const maxIdx = progressRow?.max_idx ?? -1; if (Math.floor(maxIdx / q.step) + 1 > MAX_HEAT_BUCKETS) { @@ -267,9 +289,11 @@ async function heat(url: URL, env: Env): Promise { if (buckets === undefined || cells === undefined || keys === undefined) { return json({ error: "batch returned fewer results than statements" }, 500); } - // SAFETY: the statements above select exactly the columns of BucketRow, HeatRow, and KeyRow. + // SAFETY: the first statement selects exactly BucketRow's columns. const bucketRows = buckets.results as BucketRow[]; + // SAFETY: heatRowsStatement selects exactly HeatRow's columns in every scope. const heatRows = cells.results as HeatRow[]; + // SAFETY: heatKeysStatement selects exactly KeyRow's columns in every scope. const keyRows = keys.results as KeyRow[]; return json( { step: q.step, top: q.top, scope: q.scope, buckets: bucketRows, cells: heatRows, keys: keyRows }, @@ -327,7 +351,14 @@ export default { case "/api/partitions": return partitions(url, env); case "/": - return new Response(page, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=300" } }); + return new Response(page, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=60" } }); + case "/ui.js": + return new Response(ui, { + headers: { + "content-type": "text/javascript; charset=utf-8", + "cache-control": url.searchParams.get("v") === UI_VERSION ? "public, max-age=31536000, immutable" : "no-cache", + }, + }); case "/api": return json({ name: "strata", diff --git a/src/ingest.ts b/src/ingest.ts index 43ae8e5..b156c48 100644 --- a/src/ingest.ts +++ b/src/ingest.ts @@ -1,103 +1,64 @@ -/** the record contract between the strata flow and this worker. - * - * one row per sealed stream segment, with the per-collection event counts - * decoded from the segment's collection block index. everything here is - * boundary input from an HTTP body and is parsed before it reaches D1. */ +/** the record contract between the strata flow and this worker: one row per sealed stream segment, with the + * per-collection event counts decoded from the segment's collection block index. the schema parses the HTTP + * body at the boundary; `checkIngestBatch` holds the invariants a schema cannot express. */ -export interface CollectionCount { - readonly nsid: string; - readonly count: number; -} - -/** the authority prefix of an nsid: its first two labels (`app.bsky.feed.like` → `app.bsky`). */ -export function namespaceOf(nsid: string): string { - const parts = nsid.split("."); - return parts.length <= 2 ? nsid : `${parts[0]}.${parts[1]}`; -} - -export interface SegmentRecord { - readonly idx: number; - readonly name: string; - readonly sizeBytes: number; - readonly checksum: string; - readonly eventCount: number; - readonly blockCount: number; - readonly minSeq: number; - readonly maxSeq: number; - readonly minWitnessedAt: number; - readonly maxWitnessedAt: number; - readonly collections: readonly CollectionCount[]; -} - -export interface IngestBatch { - readonly segments: readonly SegmentRecord[]; -} - -export class IngestParseError extends Error {} +import { + array, + arrayLength, + constrain, + integer, + integerRange, + object, + string, + stringLength, + type InferOutput, +} from "@atcute/lexicons/validations"; -const MAX_SEGMENTS_PER_BATCH = 200; -const MAX_COLLECTIONS_PER_SEGMENT = 1 << 20; +const count = constrain(integer(), [integerRange(0)]); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export const CollectionCountSchema = object({ + nsid: constrain(string(), [stringLength(1, 255)]), + count, +}); -function integer(source: Record, key: string, where: string): number { - const value = source[key]; - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - throw new IngestParseError(`${where}.${key}: expected a non-negative integer`); - } - return value; -} +export const SegmentRecordSchema = object({ + idx: count, + name: constrain(string(), [stringLength(1, 64)]), + sizeBytes: count, + checksum: constrain(string(), [stringLength(1, 32)]), + eventCount: count, + blockCount: count, + minSeq: count, + maxSeq: count, + minWitnessedAt: count, + maxWitnessedAt: count, + collections: constrain(array(CollectionCountSchema), [arrayLength(0, 1 << 20)]), +}); -function text(source: Record, key: string, where: string, maxLength: number): string { - const value = source[key]; - if (typeof value !== "string" || value.length === 0 || value.length > maxLength) { - throw new IngestParseError(`${where}.${key}: expected a string of 1..${maxLength} chars`); - } - return value; -} +export const IngestBatchSchema = object({ + segments: constrain(array(SegmentRecordSchema), [arrayLength(1, 200)]), +}); -function parseCollection(value: unknown, where: string): CollectionCount { - if (!isRecord(value)) throw new IngestParseError(`${where}: expected an object`); - return { nsid: text(value, "nsid", where, 255), count: integer(value, "count", where) }; -} +export type CollectionCount = InferOutput; +export type SegmentRecord = InferOutput; +export type IngestBatch = InferOutput; -function parseSegment(value: unknown, where: string): SegmentRecord { - if (!isRecord(value)) throw new IngestParseError(`${where}: expected an object`); - const rawCollections = value["collections"]; - if (!Array.isArray(rawCollections) || rawCollections.length > MAX_COLLECTIONS_PER_SEGMENT) { - throw new IngestParseError(`${where}.collections: expected an array`); - } - const collections = rawCollections.map((c, i) => parseCollection(c, `${where}.collections[${i}]`)); - const seen = new Set(); - for (const c of collections) { - if (seen.has(c.nsid)) throw new IngestParseError(`${where}.collections: duplicate nsid ${c.nsid}`); - seen.add(c.nsid); - } - const minSeq = integer(value, "minSeq", where); - const maxSeq = integer(value, "maxSeq", where); - if (maxSeq < minSeq) throw new IngestParseError(`${where}: maxSeq < minSeq`); - return { - idx: integer(value, "idx", where), - name: text(value, "name", where, 64), - sizeBytes: integer(value, "sizeBytes", where), - checksum: text(value, "checksum", where, 32), - eventCount: integer(value, "eventCount", where), - blockCount: integer(value, "blockCount", where), - minSeq, - maxSeq, - minWitnessedAt: integer(value, "minWitnessedAt", where), - maxWitnessedAt: integer(value, "maxWitnessedAt", where), - collections, - }; +/** the authority prefix of an nsid: its first two labels (`app.bsky.feed.like` → `app.bsky`). */ +export function namespaceOf(nsid: string): string { + const parts = nsid.split("."); + return parts.length <= 2 ? nsid : `${parts[0]}.${parts[1]}`; } -export function parseIngestBatch(value: unknown): IngestBatch { - if (!isRecord(value)) throw new IngestParseError("body: expected an object"); - const raw = value["segments"]; - if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_SEGMENTS_PER_BATCH) { - throw new IngestParseError(`body.segments: expected 1..${MAX_SEGMENTS_PER_BATCH} segments`); +/** invariants beyond shape: a segment's seq range runs forward and names each collection once. + * returns the first violation as a message, or null when the batch is sound. */ +export function checkIngestBatch(batch: IngestBatch): string | null { + for (const [i, segment] of batch.segments.entries()) { + if (segment.maxSeq < segment.minSeq) return `segments[${i}]: maxSeq < minSeq`; + const seen = new Set(); + for (const c of segment.collections) { + if (seen.has(c.nsid)) return `segments[${i}].collections: duplicate nsid ${c.nsid}`; + seen.add(c.nsid); + } } - return { segments: raw.map((s, i) => parseSegment(s, `segments[${i}]`)) }; + return null; } diff --git a/src/page.html b/src/page.html index 88fb292..1491f7c 100644 --- a/src/page.html +++ b/src/page.html @@ -14,57 +14,94 @@ *{margin:0;padding:0;box-sizing:border-box} html,body{background:var(--bg);color:var(--ink);font:14px/1.65 ui-monospace,"SF Mono",Menlo,Consolas,monospace} a{color:inherit;text-decoration:none} -.wrap{max-width:1180px;margin:0 auto;padding:0 2ch;display:flex;flex-direction:column;min-height:100vh} -header{display:flex;flex-direction:column;align-items:center;gap:.35rem;padding:1.7rem 0 1.4rem} +.wrap{max-width:1100px;margin:0 auto;padding:0 2ch;display:flex;flex-direction:column;min-height:100vh} +header{display:flex;flex-direction:column;align-items:center;gap:.35rem;padding:1.7rem 0 1.4rem;text-align:center} .brand{letter-spacing:.12em;font-size:.9rem;color:var(--hot)} a.brand:hover{color:var(--water)} .livedot{font-size:.74rem;color:var(--dim);font-variant-numeric:tabular-nums} .livedot b{color:var(--water);font-weight:400} -.tag{color:var(--dim);font-size:.8rem;text-align:center;max-width:64ch;margin-top:.3rem} -.tag em{font-style:normal;color:var(--ink)} -main{flex:1;display:flex;flex-direction:column;align-items:center;gap:1.6rem} -.sect{font-size:.72rem;letter-spacing:.35em;text-transform:uppercase;color:var(--dim)} -.grid{display:flex;flex-wrap:wrap;justify-content:center;gap:1.2rem 3ch;width:100%;max-width:72ch} +.tag{color:var(--dim);font-size:.8rem;max-width:64ch;margin-top:.3rem} +em{font-style:normal;color:var(--ink)} +main{flex:1;display:flex;flex-direction:column;gap:1.6rem} +.sect{font-size:.72rem;letter-spacing:.35em;text-transform:uppercase;color:var(--dim);text-align:center} +.grid{display:flex;flex-wrap:wrap;justify-content:center;gap:1.2rem 3ch;width:100%} .stat{flex:0 1 15ch;display:flex;flex-direction:column;align-items:center;gap:.15rem;text-align:center} .stat .v{font-size:1.5rem;font-weight:300;color:var(--hot);letter-spacing:.04em;font-variant-numeric:tabular-nums} .stat .l{font-size:.7rem;color:var(--dim);letter-spacing:.22em;text-transform:uppercase} -.how{color:var(--dim);font-size:.8rem;max-width:76ch;text-align:center;line-height:1.65} -.how em{font-style:normal;color:var(--ink)} +.how{color:var(--dim);font-size:.8rem;max-width:76ch;line-height:1.65;margin:0 auto;text-align:center} .how .glyph{color:var(--water)} -.controls{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem 2.4ch;color:var(--dim);font-size:.8rem;width:100%} +.controls{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem 2.4ch;color:var(--dim);font-size:.8rem} .controls label{display:inline-flex;align-items:center;gap:1ch} -select,input,button,textarea{font:inherit;font-size:.8rem;color:var(--ink);background:var(--well);border:1px solid var(--faint);padding:.25rem .8ch;border-radius:0} +select,input,button,textarea{max-width:100%;font:inherit;font-size:.8rem;color:var(--ink);background:var(--well);border:1px solid var(--faint);padding:.25rem .8ch;border-radius:0} select:focus-visible,input:focus-visible,button:focus-visible,textarea:focus-visible{outline:1px solid var(--water);outline-offset:1px} button{cursor:pointer;color:var(--dim)}button:hover{color:var(--hot);border-color:var(--water)}button:disabled{opacity:.5;cursor:default} button[aria-pressed="true"]{color:var(--water);border-color:var(--water)} -input{width:22ch}input::placeholder,textarea::placeholder{color:var(--faint)} -.section{position:relative;width:100%} -canvas#strata{display:block;width:100%;height:auto;cursor:crosshair;touch-action:pan-y} -.reader{width:100%;min-height:3.4rem;padding:.7rem 0 0;border-top:1px dotted var(--faint);font-size:.8rem;color:var(--dim);font-variant-numeric:tabular-nums;line-height:1.6} +input{width:20ch}input::placeholder,textarea::placeholder{color:var(--faint)} + +/* the layers: one grid row per app — name | activity strip | total. the name column is a fixed width so + opening an app never moves anything; children indent inside it. on phones the strip drops under the name. */ +.layers{position:relative;display:flex;flex-direction:column;gap:2px;--name:24ch;--total:7ch} +.lane{display:grid;grid-template-columns:var(--name) minmax(0,1fr) var(--total);column-gap:1.5ch;align-items:center;min-height:14px} +.lane.head{color:var(--dim);font-size:.7rem;letter-spacing:.16em;text-transform:uppercase;margin-bottom:.2rem} +.lane.head .count{text-align:right} +.lane.head .strip{background:none;height:auto} +.name{display:flex;align-items:center;gap:.6ch;background:none;border:0;padding:0;text-align:left;color:var(--ink);font-size:.8rem;min-width:0;cursor:default} +.name .glyph{color:var(--water);width:1.2ch;flex:none} +.name .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left} +.name .label bdi{direction:ltr;unicode-bidi:isolate} +.name[aria-expanded]{cursor:pointer} +.name[aria-expanded]:hover .label,.name:focus-visible .label{color:var(--hot)} +.name:focus-visible{outline:1px solid var(--water);outline-offset:2px} +.lane.child .name{padding-left:2.4ch;color:var(--dim);font-size:.76rem} +.lane.child .name .glyph{color:var(--faint)} +.strip{position:relative;height:var(--h,20px);background:var(--well);min-width:0} +.strip svg{display:block;width:100%;height:100%} +.count{font-size:.76rem;color:var(--dim);text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap} +.lane.child .count{color:var(--faint)} +.cursor{position:absolute;top:0;bottom:0;width:0;border-left:1px solid var(--hot);opacity:.7;pointer-events:none;display:none} +.axis .strip{height:32px;background:none} +.axis .era{position:absolute;top:4px;height:2px;background:var(--faint)} +.axis .era.live{background:var(--water)} +.axis .era span{position:absolute;top:6px;left:0;font-size:.7rem;color:var(--dim);white-space:nowrap} +.axis .era.live span{color:var(--water)} +.axis .tick{position:absolute;top:20px;font-size:.7rem;color:var(--dim);white-space:nowrap} +.axis .tick.end{transform:translateX(-100%)} +.reader{min-height:3.4rem;padding:.7rem 0 0;margin-top:.6rem;border-top:1px dotted var(--faint);font-size:.8rem;color:var(--dim);font-variant-numeric:tabular-nums;line-height:1.6} .reader b{color:var(--hot);font-weight:400}.reader i{font-style:normal;color:var(--water)} -.legend{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:.4rem 1.6ch;font-size:.74rem;color:var(--dim);width:100%} +.legend{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:.4rem 1.6ch;font-size:.74rem;color:var(--dim)} .legend .bar{width:22ch;height:8px;border:1px solid var(--faint)} .legend .none{display:inline-block;width:1.2em;height:8px;background:var(--well);border:1px solid var(--faint);vertical-align:-1px;margin-right:.6ch} -.note{color:var(--dim);font-size:.76rem;max-width:74ch;text-align:center;line-height:1.6} -.note em{font-style:normal;color:var(--ink)} +.note{color:var(--dim);font-size:.76rem;max-width:74ch;margin:0 auto;text-align:center;line-height:1.6} .table-wrap{width:100%;overflow-x:auto} table{border-collapse:collapse;font-size:.76rem;font-variant-numeric:tabular-nums;margin:0 auto} th,td{text-align:right;padding:.2rem 1.2ch;border-bottom:1px solid var(--well);white-space:nowrap;color:var(--dim)} td:first-child,th:first-child{text-align:left;color:var(--ink);position:sticky;left:0;background:var(--bg)} th{color:var(--dim);font-weight:400;letter-spacing:.1em;text-transform:uppercase;font-size:.66rem} -.ask{width:100%;max-width:100ch;display:flex;flex-direction:column;gap:.7rem} +.ask{width:100%;max-width:100ch;margin:0 auto;display:flex;flex-direction:column;gap:.7rem} .ask .row{display:flex;flex-wrap:wrap;gap:.5rem 2ch;align-items:center;color:var(--dim);font-size:.8rem} +.ask .row label{display:inline-flex;align-items:center;gap:1ch;max-width:100%;min-width:0} +#ask-ns{width:34ch;max-width:100%;min-width:0} .ask textarea{width:100%;min-height:5.5rem;resize:vertical;line-height:1.5;tab-size:2} .ask .prompts{display:flex;flex-wrap:wrap;gap:.4rem 1ch} .ask .prompts button{font-size:.74rem} .ask .out{font-size:.78rem;color:var(--dim);min-height:1.5rem} .ask .out b{color:var(--hot);font-weight:400} .ask .err{color:#d99a8a;white-space:pre-wrap} +.ask .fine{font-size:.74rem;color:var(--faint)} footer{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.45rem 3ch;padding:2rem 0 1.5rem;color:var(--dim);font-size:.76rem} footer a{color:var(--dim);border-bottom:1px dotted var(--faint);white-space:nowrap} footer a:hover{color:var(--hot);border-bottom-color:var(--water)} .status{color:var(--faint)} -@media (max-width:640px){.wrap{padding:0 1.5ch}.grid{gap:1rem 2ch}.stat{flex:0 1 11ch}.tag{max-width:38ch}input{width:16ch}} +@media (max-width:640px){ + .wrap{padding:0 1.5ch}.grid{gap:1rem 2ch}.stat{flex:0 1 11ch}.tag{max-width:38ch}input{width:16ch} + .layers{--name:1fr;--total:auto;gap:6px} + .lane{grid-template-columns:1fr auto;row-gap:2px;column-gap:1ch} + .lane .strip{grid-column:1/-1} + .lane.head .strip{display:none} + .lane.child{margin-left:0} + .ask .row label{width:100%} + #ask-ns{width:100%} +} @@ -79,13 +116,13 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)}
—records
—apps
—record types
-
—archive chunks
-

each row is an app, named by the identifier its records carry (app.bsky is Bluesky, sh.tangled is Tangled). a taller row has written more records overall. a brighter cell means that app was busier in that slice of the archive. ▸ opens an app to show its record types.

+

each row is an app, named by the identifier its records carry — app.bsky is Bluesky, sh.tangled is Tangled.
+ taller = more records overall · brighter = busier in that slice of the archive · ▸ = open it to see its record types

- +
@@ -135,277 +176,6 @@ footer a:hover{color:var(--hot);border-bottom-color:var(--water)} source - - + diff --git a/src/text-modules.d.ts b/src/text-modules.d.ts index 9b4de38..ea7a05c 100644 --- a/src/text-modules.d.ts +++ b/src/text-modules.d.ts @@ -2,3 +2,7 @@ declare module "*.html" { const text: string; export default text; } +declare module "*/ui.js" { + const text: string; + export default text; +} diff --git a/src/ui.js b/src/ui.js new file mode 100644 index 0000000..0744a43 --- /dev/null +++ b/src/ui.js @@ -0,0 +1,428 @@ +// strata's page: the layers picture (one DOM lane per app), the reader line, and "look inside an app" +// (duckdb-wasm over the app's public parquet files). served by the worker at ui.js; talks to api/* relatively. + +const fmt = new Intl.NumberFormat("en-US"); +const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); +const dayFmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", year: "numeric", month: "2-digit", day: "2-digit" }); +const timeFmt = new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", hour: "2-digit", minute: "2-digit" }); +const STEPS = [1, 2, 5, 10, 25, 50, 100]; +const FLOOR = 0.14; +const OTHER = "other"; +const OTHER_LABEL = "everything else"; +// the whole-network bootstrap ran 2026-07-28 → 2026-08-03 ~07:00 utc (stream /status: "backfill took 6d 2h") +const BOOTSTRAP_END_US = 1785740400000000; +const ERA_LABEL = { bootstrap: "history, replayed", live: "as it happened" }; + +function byId(id, ctor) { + const node = document.getElementById(id); + if (!(node instanceof ctor)) throw new Error(`missing #${id} (${ctor.name})`); + return node; +} +const el = { + layers: byId("layers", HTMLDivElement), + cursor: byId("cursor", HTMLDivElement), + reader: byId("reader", HTMLDivElement), + step: byId("step", HTMLSelectElement), + scale: byId("scale", HTMLSelectElement), + find: byId("find", HTMLInputElement), + nsList: byId("ns-list", HTMLDataListElement), + more: byId("more", HTMLButtonElement), + tableToggle: byId("table-toggle", HTMLButtonElement), + table: byId("table", HTMLDivElement), + live: byId("live", HTMLSpanElement), + updated: byId("updated", HTMLSpanElement), + legendBar: byId("legend-bar", HTMLSpanElement), + legendNote: byId("legend-note", HTMLSpanElement), + askNs: byId("ask-ns", HTMLSelectElement), + askSize: byId("ask-size", HTMLSpanElement), + askRun: byId("ask-run", HTMLButtonElement), + askEdit: byId("ask-edit", HTMLButtonElement), + askSql: byId("ask-sql", HTMLTextAreaElement), + askFine: byId("ask-fine", HTMLParagraphElement), + askOut: byId("ask-out", HTMLDivElement), + askTable: byId("ask-table", HTMLDivElement), + prompts: byId("prompts", HTMLDivElement), +}; +const stat = { events: byId("s-events", HTMLSpanElement), apps: byId("s-namespaces", HTMLSpanElement), types: byId("s-collections", HTMLSpanElement) }; + +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +const displayName = (key) => (key === OTHER ? OTHER_LABEL : key); +const shortName = (nsid) => nsid.slice(nsid.indexOf(".", nsid.indexOf(".") + 1) + 1); +// one hue, dark → light, with a floor so a cell with anything in it never fades into the empty tone +function shade(t) { + const u = FLOOR + (1 - FLOOR) * Math.max(0, Math.min(1, t)); + return `oklch(${(0.27 + 0.66 * u).toFixed(3)} ${(0.045 + 0.08 * u * (1 - u)).toFixed(3)} 222)`; +} +const rowHeight = (count, maxCount) => Math.round(12 + 34 * (Math.log10(Math.max(1, count)) / Math.log10(Math.max(10, maxCount)))); + +async function api(path) { + const r = await fetch(path); + return r.json(); +} + +const view = { top: 12, expanded: new Map(), pinned: [], namespaces: [], stepChosen: false }; +let picture = null; // { rows, buckets, step } +let hover = null; // { ri, bi } + +for (const s of STEPS) { + const o = document.createElement("option"); + o.value = String(s); + o.textContent = s === 1 ? "1 chunk per column" : `${s} chunks per column`; + el.step.append(o); +} + +async function loadProgress() { + const p = await api("api/progress"); + stat.events.textContent = compact.format(p.events); + stat.apps.textContent = fmt.format(p.namespaces); + stat.types.textContent = fmt.format(p.collections); + if (p.updatedAt) { + const d = new Date(p.updatedAt); + el.live.innerHTML = `· ${fmt.format(p.segments)} chunks read · ${compact.format(p.events)} records`; + el.updated.textContent = `updated ${dayFmt.format(d)} ${timeFmt.format(d)} utc`; + } + if (!view.stepChosen) el.step.value = String(STEPS.find((s) => Math.ceil(p.segments / s) <= 160) ?? STEPS[STEPS.length - 1]); + if (!view.namespaces.length) { + const { namespaces } = await api("api/namespaces"); + view.namespaces = namespaces; + el.nsList.innerHTML = namespaces.map((n) => ``).join(""); + el.askNs.innerHTML = `` + namespaces.filter((n) => n.ns !== "app.bsky").map((n) => ``).join(""); + } +} + +function seriesFor(cells, key, bucketIndex, n) { + const a = new Float64Array(n); + for (const c of cells) { + if (c.key !== key) continue; + const i = bucketIndex.get(c.bucket); + if (i !== undefined) a[i] += c.count; + } + return a; +} + +async function load() { + await loadProgress(); + const step = Number(el.step.value); + const heat = await api(`api/heat?step=${step}&top=${view.top}`); + if (heat.error) { + el.reader.innerHTML = `${esc(heat.error)}`; + return; + } + const buckets = heat.buckets; + const bucketIndex = new Map(buckets.map((b, i) => [b.bucket, i])); + const keys = new Map(heat.keys.map((k) => [k.key, k])); + const order = heat.keys.map((k) => k.key); + const extra = view.pinned.filter((p) => !keys.has(p.ns)); + const extraHeat = await Promise.all(extra.map((p) => api(`api/heat?step=${step}&ns=${p.ns}`))); + extra.forEach((p, i) => { + keys.set(p.ns, { key: p.ns, count: p.count, members: p.members, cells: extraHeat[i].cells }); + order.push(p.ns); + }); + if (heat.cells.some((c) => c.key === OTHER)) { + order.push(OTHER); + keys.set(OTHER, { key: OTHER, count: 0, members: 0 }); + } + const rows = []; + for (const key of order) { + const k = keys.get(key); + const values = k.cells ? seriesFor(k.cells, key, bucketIndex, buckets.length) : seriesFor(heat.cells, key, bucketIndex, buckets.length); + const total = key === OTHER ? values.reduce((a, b) => a + b, 0) : k.count; + rows.push({ key, depth: 0, total, members: k.members, values, expandable: key !== OTHER && k.members > 1 }); + const sub = view.expanded.get(key); + if (sub && sub.step === step) rows.push(...sub.rows); + } + picture = { rows, buckets, step }; + for (const [key, sub] of view.expanded) if (sub.step !== step) expand(key); + render(); +} + +async function expand(key) { + const step = Number(el.step.value); + const sub = await api(`api/heat?step=${step}&ns=${key}`); + const bucketIndex = new Map(sub.buckets.map((b, i) => [b.bucket, i])); + const rows = sub.keys.map((k) => ({ key: k.key, depth: 1, total: k.count, members: 1, values: seriesFor(sub.cells, k.key, bucketIndex, sub.buckets.length), expandable: false })); + view.expanded.set(key, { step, rows }); + if (picture && picture.step === step) { + dropChildren(key); + const at = picture.rows.findIndex((r) => r.key === key && r.depth === 0); + if (at >= 0) picture.rows.splice(at + 1, 0, ...rows); + } + render(); +} +function dropChildren(key) { + const at = picture.rows.findIndex((r) => r.key === key && r.depth === 0); + if (at < 0) return; + let end = at + 1; + while (end < picture.rows.length && picture.rows[end].depth === 1) end++; + picture.rows.splice(at + 1, end - at - 1); +} +function collapse(key) { + view.expanded.delete(key); + if (picture) dropChildren(key); + render(); +} + +const eraOf = (b) => (b.min_witnessed_at < BOOTSTRAP_END_US ? "bootstrap" : "live"); + +function render() { + if (!picture) return; + const { rows, buckets } = picture; + const scale = el.scale.value; + const n = buckets.length; + const maxTotal = Math.max(...rows.filter((r) => r.depth === 0).map((r) => r.total)); + const maxPerRow = rows.map((r) => Math.max(0, ...r.values)); + const logMax = Math.log10(Math.max(10, ...maxPerRow)); + const cellW = Math.max(0.5, 1 - Math.min(0.15, 24 / n)); // a hairline gap when columns are wide enough to show one + + const lanes = rows.map((r, ri) => { + const h = Math.max(r.depth ? 11 : 12, rowHeight(r.total, maxTotal) - (r.depth ? 8 : 0)); + const rects = []; + for (let bi = 0; bi < n; bi++) { + const v = r.values[bi]; + if (v <= 0) continue; + const t = scale === "row" ? v / maxPerRow[ri] : Math.log10(v) / logMax; + rects.push(``); + } + const glyph = r.expandable ? (view.expanded.has(r.key) ? "▾" : "▸") : r.depth ? "·" : ""; + const label = r.depth ? shortName(r.key) : displayName(r.key); + const expandAttr = r.expandable ? ` aria-expanded="${view.expanded.has(r.key)}"` : ""; + return `
` + + `` + + `
` + + `${compact.format(r.total)}
`; + }); + + const eras = []; + let start = 0; + for (let bi = 1; bi <= n; bi++) { + if (bi === n || eraOf(buckets[bi]) !== eraOf(buckets[start])) { + const era = eraOf(buckets[start]); + const wide = bi - start > n / 6; + eras.push(`
${wide ? `${ERA_LABEL[era]}` : ""}
`); + start = bi; + } + } + const ticks = []; + let lastDay = ""; + let lastAt = -1; + for (let bi = 0; bi < n; bi++) { + const day = dayFmt.format(new Date(buckets[bi].min_witnessed_at / 1000)); + if (day !== lastDay && bi - lastAt > n / 8) { + ticks.push(`${day}`); + lastDay = day; + lastAt = bi; + } + } + const axis = `
${eras.join("")}${ticks.join("")}
`; + + el.layers.innerHTML = `
apprecords
${lanes.join("")}${axis}
`; + el.cursor = byId("cursor", HTMLDivElement); + el.legendBar.style.background = `linear-gradient(90deg, ${[0, 0.25, 0.5, 0.75, 1].map(shade).join(",")})`; + el.legendNote.textContent = scale === "row" ? "· brightest = that app's busiest column" : "· same scale for every app (log)"; + renderTable(); +} + +function cellFromPointer(ev) { + if (!picture) return null; + const lane = ev.target instanceof Element ? ev.target.closest(".lane[data-ri]") : null; + if (!lane) return null; + const strip = lane.querySelector(".strip"); + if (!(strip instanceof HTMLElement)) return null; + const rect = strip.getBoundingClientRect(); + const ri = Number(lane.dataset.ri); + const x = (ev.clientX - rect.left) / rect.width; + const bi = x < 0 || x >= 1 ? -1 : Math.floor(x * picture.buckets.length); + return { ri, bi, rect }; +} + +function readCell(c) { + const r = picture.rows[c.ri]; + const what = r.depth ? "record type" : r.key === OTHER ? "the apps not shown" : "app"; + if (c.bi < 0) { + el.reader.innerHTML = `${esc(displayName(r.key))} · ${what} · ${fmt.format(r.total)} records in the archive${r.expandable ? ` across ${r.members} record types · click the name to open` : ""}`; + return; + } + const b = picture.buckets[c.bi]; + const v = r.values[c.bi]; + const from0 = dayFmt.format(new Date(b.min_witnessed_at / 1000)); + if (v <= 0) { + el.reader.innerHTML = `nothing from ${esc(displayName(r.key))} in this slice (received ${from0} utc)`; + return; + } + const share = b.event_count ? (100 * v) / b.event_count : 0; + const from = dayFmt.format(new Date(b.min_witnessed_at / 1000)); + const to = dayFmt.format(new Date(b.max_witnessed_at / 1000)); + const chunk = b.last_idx !== b.first_idx ? `chunks #${b.first_idx}–${b.last_idx}` : `chunk #${b.first_idx}`; + el.reader.innerHTML = + `${esc(displayName(r.key))} wrote ${fmt.format(v)} records in this slice — ${share < 0.01 && v ? "under 0.01" : share.toFixed(2)}% of the ${compact.format(b.event_count)} in it
` + + `archive ${chunk} · received ${from}${to !== from ? ` → ${to}` : ""} utc · record numbers ${fmt.format(b.min_seq)} – ${fmt.format(b.max_seq)}`; +} + +function showCursor(c) { + if (c.bi < 0) { + el.cursor.style.display = "none"; + return; + } + const host = el.layers.getBoundingClientRect(); + const w = c.rect.width / picture.buckets.length; + el.cursor.style.display = "block"; + el.cursor.style.left = `${c.rect.left - host.left + c.bi * w}px`; + el.cursor.style.width = `${Math.max(w, 1)}px`; + el.cursor.style.borderLeftWidth = w >= 3 ? "0" : "1px"; + el.cursor.style.background = w >= 3 ? "rgba(226,235,240,.08)" : "transparent"; +} + +function clearHover() { + hover = null; + el.cursor.style.display = "none"; + el.reader.innerHTML = 'move over the picture to read a cell · open an app to see its record types'; +} + +el.layers.addEventListener("pointermove", (ev) => { + const c = cellFromPointer(ev); + if (!c) { + clearHover(); + return; + } + hover = { ri: c.ri, bi: c.bi }; + showCursor(c); + readCell(c); +}); +el.layers.addEventListener("pointerleave", clearHover); +el.layers.addEventListener("click", (ev) => { + const button = ev.target instanceof Element ? ev.target.closest("button.name[aria-expanded]") : null; + if (!(button instanceof HTMLButtonElement)) return; + const key = button.dataset.key ?? ""; + if (view.expanded.has(key)) collapse(key); + else expand(key); +}); + +function renderTable() { + if (!picture || el.table.hidden) return; + const { rows, buckets } = picture; + el.table.innerHTML = + `${buckets.map((b) => ``).join("")}` + + rows.map((r) => `${Array.from(r.values, (v) => ``).join("")}`).join("") + + `
app / record typerecords#${b.first_idx}
${r.depth ? "  · " : ""}${esc(displayName(r.key))}${fmt.format(r.total)}${v ? fmt.format(v) : ""}
`; +} +el.tableToggle.addEventListener("click", () => { + el.table.hidden = !el.table.hidden; + el.tableToggle.setAttribute("aria-pressed", String(!el.table.hidden)); + renderTable(); +}); +el.more.addEventListener("click", () => { + view.top = Math.min(60, view.top + 12); + el.more.disabled = view.top >= 60; + load(); +}); +el.step.addEventListener("change", () => { + view.stepChosen = true; + load(); +}); +el.scale.addEventListener("change", render); +el.find.addEventListener("change", () => { + const ns = el.find.value.trim().toLowerCase(); + const hit = view.namespaces.find((n) => n.ns === ns); + if (!hit) { + el.reader.innerHTML = `no app called ${esc(ns)} has written to the archive yet`; + return; + } + if (!view.pinned.some((p) => p.ns === ns)) view.pinned.push(hit); + el.find.value = ""; + load(); +}); +window.addEventListener("resize", () => { + if (hover) el.cursor.style.display = "none"; +}); + +// ---- look inside an app ---- +const PROMPTS = [ + ["what it writes", "select collection as record_type, op as action, count(*) as records, count(distinct did) as accounts\nfrom rows group by all order by records desc"], + ["people per day", "select strftime(make_timestamp(time_us), '%Y-%m-%d') as day, count(distinct did) as accounts, count(*) as records\nfrom rows where kind = 'commit' group by 1 order by 1"], + ["when each type first appeared", "select collection as record_type, strftime(make_timestamp(min(time_us)), '%Y-%m-%d %H:%M') as first_seen, count(*) as records\nfrom rows where kind = 'commit' group by 1 order by min(time_us)"], + ["most active accounts", "select did as account, count(*) as records, count(distinct collection) as record_types\nfrom rows where kind = 'commit' group by 1 order by 2 desc limit 20"], + ["creates vs deletes", "select op as action, count(*) as records from rows where kind = 'commit' group by 1 order by 2 desc"], +]; +for (const [label, sql] of PROMPTS) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = label; + b.addEventListener("click", () => { + el.askSql.value = sql; + if (el.askNs.value) runAsk(); + }); + el.prompts.append(b); +} +el.askSql.value = PROMPTS[0][1]; +el.askEdit.addEventListener("click", () => { + el.askSql.hidden = !el.askSql.hidden; + el.askFine.hidden = el.askSql.hidden; + el.askEdit.setAttribute("aria-pressed", String(!el.askSql.hidden)); + if (!el.askSql.hidden) el.askSql.focus(); +}); + +let duck = null; +let partition = null; +async function duckdb() { + if (duck) return duck; + el.askOut.innerHTML = "loading the query engine…"; + const mod = await import("https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm"); + const bundle = await mod.selectBundle(mod.getJsDelivrBundles()); + const worker = new Worker(URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }))); + const db = new mod.AsyncDuckDB(new mod.ConsoleLogger(mod.LogLevel.WARNING), worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + duck = await db.connect(); + return duck; +} +el.askNs.addEventListener("change", async () => { + partition = null; + el.askRun.disabled = true; + el.askTable.innerHTML = ""; + el.askOut.innerHTML = ""; + if (!el.askNs.value) { + el.askSize.textContent = "pick one"; + return; + } + el.askSize.textContent = "checking…"; + const p = await api(`api/partitions?ns=${el.askNs.value}`); + if (p.error) { + el.askSize.textContent = p.error; + return; + } + partition = p; + const size = p.bytes < 1 << 20 ? `${Math.round(p.bytes / 1024)} KB` : `${(p.bytes / (1 << 20)).toFixed(1)} MB`; + el.askSize.textContent = p.files.length ? `${size} to read from ${p.files.length} chunk${p.files.length === 1 ? "" : "s"}` : "not copied out of the archive yet"; + el.askRun.disabled = !p.files.length; +}); +function formatCell(value, numeric) { + if (value === null || value === undefined) return ""; + return numeric ? fmt.format(value) : esc(value); +} +async function runAsk() { + if (!partition || !partition.files.length) return; + el.askRun.disabled = true; + try { + const conn = await duckdb(); + await conn.query(`create or replace view rows as select * from read_parquet([${partition.files.map((f) => `'${f}'`).join(", ")}])`); + const t = performance.now(); + const res = await conn.query(el.askSql.value); + const fields = res.schema.fields.map((f) => ({ name: f.name, numeric: /Int|Float|Decimal/.test(String(f.type)) })); + const out = res.toArray().map((r) => r.toJSON()); + el.askOut.innerHTML = `${fmt.format(out.length)} row${out.length === 1 ? "" : "s"} in ${(performance.now() - t).toFixed(0)} ms`; + el.askTable.innerHTML = + `${fields.map((f) => ``).join("")}` + + out.slice(0, 500).map((r) => `${fields.map((f) => ``).join("")}`).join("") + + `
${esc(f.name.replaceAll("_", " "))}
${formatCell(r[f.name], f.numeric)}
` + + (out.length > 500 ? `

showing 500 of ${fmt.format(out.length)}

` : ""); + } catch (err) { + el.askOut.innerHTML = `${esc(err instanceof Error ? err.message : err)}`; + } finally { + el.askRun.disabled = false; + } +} +el.askRun.addEventListener("click", runAsk); +el.askSql.addEventListener("keydown", (ev) => { + if ((ev.metaKey || ev.ctrlKey) && ev.key === "Enter") runAsk(); +}); + +load(); diff --git a/tests/ingest.test.ts b/tests/ingest.test.ts index 6090fdb..e8087d6 100644 --- a/tests/ingest.test.ts +++ b/tests/ingest.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { IngestParseError, namespaceOf, parseIngestBatch } from "../src/ingest"; +import { safeParse } from "@atcute/lexicons/validations"; + +import { IngestBatchSchema, checkIngestBatch, namespaceOf } from "../src/ingest"; const segment = { idx: 6000, @@ -21,27 +23,33 @@ const segment = { } as const; test("accepts a well-formed batch and preserves values", () => { - const batch = parseIngestBatch({ segments: [segment] }); - assert.equal(batch.segments.length, 1); - assert.deepEqual(batch.segments[0], segment); + const result = safeParse(IngestBatchSchema, { segments: [segment] }); + assert.ok(result.ok); + assert.equal(result.value.segments.length, 1); + assert.deepEqual(result.value.segments[0], segment); + assert.equal(checkIngestBatch(result.value), null); }); test("rejects an empty batch", () => { - assert.throws(() => parseIngestBatch({ segments: [] }), IngestParseError); + assert.equal(safeParse(IngestBatchSchema, { segments: [] }).ok, false); }); -test("rejects duplicate collections within a segment", () => { - const dup = { ...segment, collections: [...segment.collections, { nsid: "app.bsky.feed.like", count: 1 }] }; - assert.throws(() => parseIngestBatch({ segments: [dup] }), /duplicate nsid/); +test("rejects non-integer counts", () => { + const bad = { ...segment, collections: [{ nsid: "app.bsky.feed.like", count: "many" }] }; + assert.equal(safeParse(IngestBatchSchema, { segments: [bad] }).ok, false); }); -test("rejects seq ranges that run backwards", () => { - assert.throws(() => parseIngestBatch({ segments: [{ ...segment, minSeq: 5, maxSeq: 4 }] }), /maxSeq < minSeq/); +test("flags duplicate collections within a segment", () => { + const dup = { ...segment, collections: [...segment.collections, { nsid: "app.bsky.feed.like", count: 1 }] }; + const result = safeParse(IngestBatchSchema, { segments: [dup] }); + assert.ok(result.ok); + assert.match(checkIngestBatch(result.value) ?? "", /duplicate nsid/); }); -test("rejects non-integer counts", () => { - const bad = { ...segment, collections: [{ nsid: "app.bsky.feed.like", count: "many" }] }; - assert.throws(() => parseIngestBatch({ segments: [bad] }), /count: expected a non-negative integer/); +test("flags seq ranges that run backwards", () => { + const result = safeParse(IngestBatchSchema, { segments: [{ ...segment, minSeq: 5, maxSeq: 4 }] }); + assert.ok(result.ok); + assert.match(checkIngestBatch(result.value) ?? "", /maxSeq < minSeq/); }); test("namespaceOf takes the first two labels", () => { diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 0000000..2b4ae22 --- /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 0000000..0d11852 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === "TSAsExpression" || node.type === "TSTypeAssertion"; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "const" + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === "ParenthesizedExpression" && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.", + }, + messages: { + chained: + "This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.", + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: "chained" }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..ae7248d --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === "ParenthesizedExpression") { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === "ObjectExpression" && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === "ConditionalExpression" && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: "suggestion", + docs: { + description: + "Disallow object spreads that conditionally spread an empty object to omit fields.", + }, + messages: { + avoid: + "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.", + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== "ObjectExpression") return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: "avoid" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000..2a6806c --- /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 0000000..d6fb5b4 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,91 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== "ImportSpecifier") return null; + return node.imported.type === "Identifier" ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression, +): expression is ESTree.IdentifierReference { + if (expression.type !== "Identifier") return false; + if ( + (expression.name === "vi" || expression.name === "jest") && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === "vi" || expression.name === "jest"; + } + return variable.defs.some((definition) => { + if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === "Literal" && + (property.value === "doMock" || + property.value === "mock" || + property.value === "unstable_mockModule") + ? property.value + : null + : property.type === "Identifier" + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.", + }, + messages: { + moduleMock: + "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: "moduleMock" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 0000000..29b990f --- /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 0000000..2cc3045 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.", + }, + messages: { + reflectApply: + "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) { + context.report({ node, messageId: "reflectApply" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000..cf630ec --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from "@oxlint/plugins"; + +import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts"; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.", + }, + messages: { + reflectGet: + "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.", + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) { + context.report({ node, messageId: "reflectGet" }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000..6a25c24 --- /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 0000000..afc00dd --- /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 0000000..cdc6c23 --- /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 0000000..4b16d6e --- /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 0000000..3e328fd --- /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 0000000..8c45eed --- /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 0000000..c5e07f7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree, Variable } from "@oxlint/plugins"; + +type BroadTypeKind = "top" | "object" | "record"; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + "TSDeclareFunction", + "TSEmptyBodyFunctionExpression", +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === "ParenthesizedExpression") current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === "Identifier" ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === "TSStringKeyword" || + unwrapped.type === "TSNumberKeyword" || + unwrapped.type === "TSSymbolKeyword" + ) { + return true; + } + if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === "TSTypeReference") { + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== "Record") return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; + return ( + member?.type === "TSIndexSignature" && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; + if (unwrapped.type === "TSObjectKeyword") return "object"; + return isBroadRecordType(unwrapped) ? "record" : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion, +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression, +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType, +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case "TSArrayType": + case "TSConstructorType": + case "TSFunctionType": + case "TSMappedType": + case "TSObjectKeyword": + case "TSTupleType": + return true; + case "TSTypeLiteral": + return unwrapped.members.length > 0; + case "TSIntersectionType": + return unwrapped.types.every(isDefinitelyObjectType); + case "TSTypeOperator": + return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === "TSTypeLiteral") { + return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + } + + if (unwrapped.type !== "TSTypeReference") return false; + if (typeReferenceName(unwrapped) === "Readonly") { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== "Record") return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== "Program") { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference, +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + (candidate) => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end, + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { + return { type: null }; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "NewExpression" || + unwrapped.type === "ObjectExpression" + ) { + return { type: null }; + } + + if (unwrapped.type !== "Identifier") return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + (identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined, + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]), + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0], +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== "VariableDeclaration" || + declarator.parent.kind !== "const" || + declarator.id.type !== "Identifier" || + declarator.init === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType, +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === "top") return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === "object") return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== "Identifier") return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation, + ) + ) { + return; + } + + context.report({ + node, + messageId: "widenThenAssert", + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000..f1a2ffc --- /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 0000000..8651700 --- /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 0000000..7cdb18c --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,61 @@ +import type { ESTree } from "@oxlint/plugins"; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === "object" && + value !== null && + "type" in value && + typeof value.type === "string" + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + if ("typeParameters" in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === "TSMappedType" && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === "TSConditionalType" && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 0000000..39bc218 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins"; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference, +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== "Identifier" || expression.name !== "Reflect") return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string, +): boolean { + if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === "Literal" && property.value === methodName + : property.type === "Identifier" && property.name === methodName; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 2ed24c5..e22c599 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "strata", "main": "src/index.ts", - "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": true }], + "rules": [{ "type": "Text", "globs": ["**/*.html", "**/ui.js"], "fallthrough": true }], "compatibility_date": "2026-08-01", "observability": { "enabled": true }, "workers_dev": false,