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 ルールで機械的に落とす。

1 — 概要

anti-slop(dmmulroy/anti-slop)は、TypeScript / JavaScript 向けの Oxlint プラグイン。ESLint ではなく Oxlint の JS プラグイン API(@oxlint/plugins の eslintCompatPlugin / defineRule)で書かれており、Oxlint の ESTree AST だけを使う。型情報を必要としないため、実行は速い。

15ルール
OxlintJS プラグイン
Vendor依存でなくコピー
MITライセンス

特徴的なのは配布方針で、npm 依存として固定するのではなく「自分のリポジトリへコピーして使う(vendoring)」ことを前提にしている。同梱の Agent Skill が初回のコピーと設定を担当し、以降そのファイルは利用側のものとして読み替え・改変してよい、という立場をとる。

2 — 何を「slop」と見るか

README の言葉では対象は low-evidence(根拠が薄い)かつ low-signal(情報量が乏しい)なパターン。型検査が通ることと、その型が何かを保証していることは別だ、という前提に立っている。ルールのエラーメッセージはどれも「代わりに何をすべきか」まで書かれている。

外部入力 → I/O 境界で 1 回パースする → 名前のあるドメイン型 → その型のまま持ち回る

この一本道から外れる動きが、そのまま検出対象になる。

  • 型を広げる: 分かっている値をわざわざ unknown / object / Record<string, unknown> へ落とす。
  • 広げてから戻す: 広げた値を後から as で元の型に戻し、根拠のない「証拠」を作る。
  • 境界を持たない: パースの代わりに typeof でその場しのぎに絞り込む。
  • 型を迂回する: Reflect.get / Reflect.apply で型付きアクセスを回避する。
  • 構造で名づける: UserShape のように役割ではなく形で命名する。
  • 依存を偽装する: モジュールモックでシームの設計をごまかす。
ルールは意図的に汎用。 リポジトリのガイダンスでは「アプリ固有の名前・パス・例外をルールに足さない」ことが明示されている。プロジェクト固有の規約は別プラグインに分けるのが前提。
3 — ルール一覧

すべて "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 など)。 実インターフェイス経由の依存注入、サービス層、または忠実なテスト実装。
例外は 2 つだけ。 エラーの原因を運ぶ cause(unknown を許可)と、as const(SAFETY コメント不要)。それ以外に緩和口は用意されていない。
4 — Before / After

no-widen-then-assert — 精度のある値を一度 unknown に落とし、後で as で復元する流れ。アサーションは何も検査していないので、実質「元の型を書き直しただけ」になる。

Rejected
typescript
const loaded: User = loadUser();
const stored: unknown = loaded;
const user = stored as User;
Preferred
typescript
const user = loadUser();
// 初期化時の型をそのまま使用箇所まで運ぶ

no-known-value-widening — リテラルで書いたキーが分かっているのに、広い注釈を付けて推論結果を捨てている。

Rejected
typescript
const handlers: Record<string, Handler> = {
  start: startHandler,
};
Preferred
typescript
const handlers = {
  start: startHandler,
} satisfies Record<string, Handler>;

satisfies なら制約は検査したまま、start というキーの情報が残る。

no-runtime-typeof / no-unknown-parameters — typeof は「表現」を絞るだけで「契約」を作らない。入力は境界で 1 回デコードし、以降はドメイン型で扱う。

Rejected
typescript
function handle(input: unknown) {
  if (typeof input === "string") {
    useName(input);
  }
}
Preferred
typescript
// 境界: 受信直後に一度だけパースする
const command = parseCommand(payload);

function handle(command: Command) {
  useName(command.name);
}

require-safety-comment-for-type-assertion — アサーションを全面禁止にはせず、「TypeScript が表現できない不変条件を人が書く」ことを要求する。SAFETY: はアサーション本体、またはそれを含む文(VariableDeclaration / ReturnStatement など)の直前コメントとして探索される。

Rejected
typescript
const userId = value as UserId;
Preferred
typescript
// SAFETY: parseUserId validated the identifier before branding it.
const userId = value as UserId;

残りの代表例。いずれも「型はあるが、その値について何も分からない」形をしている。

Rejected
typescript
// 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");
Preferred
typescript
// スキーマ由来の値型を持つ辞書
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();
5 — 導入手順

同梱の install-anti-slop スキルを取得し、コーディングエージェントに「このリポジトリに anti-slop を入れて」と頼む。スキルはプラグインのコピー、Oxlint 依存のインストール、既存 lint 設定へのマージ、全ルールの有効化、検証までを 1 手順として扱う。

shell
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 に入れる。コピー先のエントリポイントを設定に登録する。

oxlint.config.ts
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 の最後に合わせる。
導入後に findings が出たとき。 スキルは「ルールを抑制する・severity を下げる・安全でないキャストを足す・型を機械的に洗浄する」ことを禁じている。修正するのは、利用者が移行・クリーンアップを依頼したときだけ。
6 — 運用とメンテナンス

コピーしたファイルは利用側の資産になる。チームの基準に合わせて読み・変え・育てることが想定された配布形態で、上流に追随し続ける義務はない。

  • 古い自前コピーを置き換えるときは、上書き前にルールと診断メッセージを突き合わせる。
  • プロジェクト固有のルールは別プラグインに置く。anti-slop 側は汎用のまま保つ。
  • 指摘の解消は as ではなく、推論・as const・satisfies・名前付き契約・境界でのパースで行う。

src/ が正であり、スキルが同梱するアセットはその複製。両者がずれていないことを CI が検査する。

shell
pnpm install
pnpm check   # lint + test + typecheck + check:skill-assets

# production source を変更したあと
pnpm sync:skill-assets
  • ルールは Oxlint の ESTree API で書く。別のパーサを本番依存に足さない。
  • 意味を変えるルール変更には RuleTester のケースを添える。
  • ルールに特定アプリの名前・パス・例外を持ち込まない。