anti-slop
Opinionated Oxlint rules that reject low-evidence and low-signal TypeScript and JavaScript patterns.
型注釈は付いているのに、その型を裏づける根拠がどこにもない。unknown を受け取り、as で戻し、Record<string, unknown> に詰める。コンパイルは通るが、契約は何も語っていない。anti-slop はこの手のコードを 15 本の Oxlint ルールで機械的に落とす。
anti-slop(dmmulroy/anti-slop)は、TypeScript / JavaScript 向けの Oxlint プラグイン。ESLint ではなく Oxlint の JS プラグイン API(@oxlint/plugins の eslintCompatPlugin / defineRule)で書かれており、Oxlint の ESTree AST だけを使う。型情報を必要としないため、実行は速い。
特徴的なのは配布方針で、npm 依存として固定するのではなく「自分のリポジトリへコピーして使う(vendoring)」ことを前提にしている。同梱の Agent Skill が初回のコピーと設定を担当し、以降そのファイルは利用側のものとして読み替え・改変してよい、という立場をとる。
README の言葉では対象は low-evidence(根拠が薄い)かつ low-signal(情報量が乏しい)なパターン。型検査が通ることと、その型が何かを保証していることは別だ、という前提に立っている。ルールのエラーメッセージはどれも「代わりに何をすべきか」まで書かれている。
この一本道から外れる動きが、そのまま検出対象になる。
- 型を広げる: 分かっている値をわざわざ
unknown/object/Record<string, unknown>へ落とす。 - 広げてから戻す: 広げた値を後から
asで元の型に戻し、根拠のない「証拠」を作る。 - 境界を持たない: パースの代わりに
typeofでその場しのぎに絞り込む。 - 型を迂回する:
Reflect.get/Reflect.applyで型付きアクセスを回避する。 - 構造で名づける:
UserShapeのように役割ではなく形で命名する。 - 依存を偽装する: モジュールモックでシームの設計をごまかす。
すべて "error" で有効にする想定。オプションを持つのは no-runtime-typeof だけ。
| ルール | 分類 | 弾くもの | 代わりに |
|---|---|---|---|
no-unknown-parameters |
型契約 | 引数の unknown。cause という名前の引数だけ例外。 |
名前のあるドメイン型を受け取り、パースは呼び出し前の境界で済ませる。 |
no-unknown-returns |
型契約 | 戻り値が unknown / Promise<unknown> の関数。 |
境界でパースし、名前の付いたドメイン型を返す。 |
no-unknown-type-aliases |
型契約 | unknown を別名で覆い隠す型エイリアス(多段エイリアスも追跡)。 |
unknown は境界と cause に限って明示的に残す。 |
no-object-parameters |
型契約 | 引数の広い object 型。エイリアスやユニオン経由も解決して検出。 |
所有者のはっきりした名前付き型を受け取る。 |
no-unsafe-dictionary-type |
型契約 | 値型が unknown / any / object / {} とその同義な辞書型(Record、インデックスシグネチャ、Mapped Type)。 |
スキーマ由来の値型を使い、外部ペイロードは投入前にパースする。 |
no-known-value-widening |
型契約 | 分かっている値に広い型注釈を付けて証拠を捨てる代入。 | 推論に任せる、satisfies で検証する、または名前付き契約を使う。 |
no-widen-then-assert |
型契約 | ローカルで一度型を広げ、後から as で戻すフロー。 |
初期化時の精度を使用箇所まで保つ。境界のパースは 1 回だけ。 |
no-chained-type-assertions |
型契約 | x as object as User のような多段アサーション。 |
元の精度を保つか、境界で untrusted 入力をパースしてから絞る。 |
require-safety-comment-for-type-assertion |
境界 | as const 以外の型アサーションで、直前に SAFETY: コメントがないもの。 |
アサーション(またはそれを含む文)の直前に、確認済みの不変条件を書く。 |
no-runtime-typeof |
境界 | typeof による実行時の場当たり的な絞り込み。 |
I/O 境界でデコードし、ドメイン値で分岐する。allowInTypeGuards: true で型述語・アサーション関数の内側のみ許可可(既定 false)。 |
no-reflect-get |
境界 | Reflect.get による型付きアクセスの迂回。 |
型付きのプロパティアクセス、または動的入力のパース。 |
no-reflect-apply |
境界 | Reflect.apply による動的呼び出し。 |
型付きの関数呼び出し。動的ディスパッチは名前付きインターフェイスの背後に置く。 |
no-conditional-empty-object-spread |
境界 | ...(cond ? { x } : {}) のように空オブジェクトでフィールドを省く条件スプレッド。 |
オブジェクトを別の文で組み立て、値があるときだけプロパティを足す。 |
no-shape-in-symbol-names |
命名 | 識別子・private 識別子・JSX 識別子に含まれる大文字小文字を問わない shape。 |
構造ではなくドメイン上の役割で命名する。 |
no-module-mocking |
テスト | Vitest / Jest のモジュールモック(vi.mock など)。 |
実インターフェイス経由の依存注入、サービス層、または忠実なテスト実装。 |
cause(unknown を許可)と、as const(SAFETY コメント不要)。それ以外に緩和口は用意されていない。
no-widen-then-assert — 精度のある値を一度 unknown に落とし、後で as で復元する流れ。アサーションは何も検査していないので、実質「元の型を書き直しただけ」になる。
const loaded: User = loadUser();
const stored: unknown = loaded;
const user = stored as User;
const user = loadUser();
// 初期化時の型をそのまま使用箇所まで運ぶ
no-known-value-widening — リテラルで書いたキーが分かっているのに、広い注釈を付けて推論結果を捨てている。
const handlers: Record<string, Handler> = {
start: startHandler,
};
const handlers = {
start: startHandler,
} satisfies Record<string, Handler>;
satisfies なら制約は検査したまま、start というキーの情報が残る。
no-runtime-typeof / no-unknown-parameters — typeof は「表現」を絞るだけで「契約」を作らない。入力は境界で 1 回デコードし、以降はドメイン型で扱う。
function handle(input: unknown) {
if (typeof input === "string") {
useName(input);
}
}
// 境界: 受信直後に一度だけパースする
const command = parseCommand(payload);
function handle(command: Command) {
useName(command.name);
}
require-safety-comment-for-type-assertion — アサーションを全面禁止にはせず、「TypeScript が表現できない不変条件を人が書く」ことを要求する。SAFETY: はアサーション本体、またはそれを含む文(VariableDeclaration / ReturnStatement など)の直前コメントとして探索される。
const userId = value as UserId;
// SAFETY: parseUserId validated the identifier before branding it.
const userId = value as UserId;
残りの代表例。いずれも「型はあるが、その値について何も分からない」形をしている。
// no-unsafe-dictionary-type
type Metadata = Record<string, unknown>;
type OtherMetadata = { [key: string]: object };
// no-conditional-empty-object-spread
const options = {
...(timeout !== undefined ? { timeout } : {}),
};
// no-shape-in-symbol-names
interface UserShape { id: string }
// no-chained-type-assertions
const user = input as object as User;
// no-module-mocking
vi.mock("./user-store");
// スキーマ由来の値型を持つ辞書
type Metadata = Record<MetadataKey, MetadataValue>;
// 条件は文として書く
const options: RequestOptions = { url };
if (timeout !== undefined) options.timeout = timeout;
// 役割で名づける
interface User { id: UserId }
// 境界でパースしてから使う
const user = parseUser(input);
// 実インターフェイス経由で差し替える
const store = createInMemoryUserStore();
同梱の install-anti-slop スキルを取得し、コーディングエージェントに「このリポジトリに anti-slop を入れて」と頼む。スキルはプラグインのコピー、Oxlint 依存のインストール、既存 lint 設定へのマージ、全ルールの有効化、検証までを 1 手順として扱う。
npx skills add dmmulroy/anti-slop --skill install-anti-slop
# 先に中身を確認したいとき
npx skills add dmmulroy/anti-slop --list
スキルの手順は、バージョンを記憶に頼らないよう npm view oxlint version / npm view @oxlint/plugins version で現行版を調べ、同じバージョンを揃えて入れることまで指示している。既存の anti-slop がある場合は差分を見ずに上書きしない。
src/ を対象リポジトリ(例: tools/oxlint/anti-slop/)へコピーし、oxlint と @oxlint/plugins を同じバージョンで devDependency に入れる。コピー先のエントリポイントを設定に登録する。
import { defineConfig } from "oxlint";
export default defineConfig({
ignorePatterns: [
".agent/**", ".agents/**", ".claude/**", ".codex/**",
".continue/**", ".cursor/**", ".gemini/**", ".opencode/**",
".pi/**", ".roo/**", ".windsurf/**",
"tools/oxlint/anti-slop/**",
],
jsPlugins: [
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
],
rules: {
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
"anti-slop/no-shape-in-symbol-names": "error",
"anti-slop/no-unknown-parameters": "error",
"anti-slop/no-unknown-returns": "error",
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error"
}
});
- ignorePatterns は追記する。 既存の除外を消さない。エージェントツールのディレクトリは「インストール物」として除外し、ドット始まりを一律で無視しない(自前のソースを置いているリポジトリがあるため)。
- Vite+ の場合: 同じ内容を
lint.ignorePatterns/lint.jsPluginsに置き、加えてfmt.ignorePatternsにも同じパターンを入れる。そうしないとvp checkが vendoring したプラグインを整形してしまう。 - プラグイン自体も lint 対象外にする。 コピー先パスを
ignorePatternsの最後に合わせる。
コピーしたファイルは利用側の資産になる。チームの基準に合わせて読み・変え・育てることが想定された配布形態で、上流に追随し続ける義務はない。
- 古い自前コピーを置き換えるときは、上書き前にルールと診断メッセージを突き合わせる。
- プロジェクト固有のルールは別プラグインに置く。anti-slop 側は汎用のまま保つ。
- 指摘の解消は
asではなく、推論・as const・satisfies・名前付き契約・境界でのパースで行う。
src/ が正であり、スキルが同梱するアセットはその複製。両者がずれていないことを CI が検査する。
pnpm install
pnpm check # lint + test + typecheck + check:skill-assets
# production source を変更したあと
pnpm sync:skill-assets
- ルールは Oxlint の ESTree API で書く。別のパーサを本番依存に足さない。
- 意味を変えるルール変更には RuleTester のケースを添える。
- ルールに特定アプリの名前・パス・例外を持ち込まない。
- GitHub: dmmulroy/anti-slop — 本体リポジトリ(README にルール別の違反例あり)
- skills.sh: dmmulroy/anti-slop —
install-anti-slopスキルの配布ページ - Oxlint ドキュメント — リンタ本体と JS プラグインの仕組み
- TypeScript: satisfies 演算子 — 推論を保ったまま制約を検査する
- Vite+ —
lint/fmt設定へのマージ先として言及されているツールチェーン