Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .claude/hooks/biome-format.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node
// PostToolUse(Edit|Write) hook: 編集されたファイルを Biome で整形+import 整理する(非ブロッキング)。
import { execFileSync } from 'node:child_process';

const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();

const file = await readFilePath();
if (!file || !/\.(tsx?|jsx?|mjs|cjs|jsonc?)$/.test(file)) {
process.exit(0);
}

try {
execFileSync('pnpm', ['exec', 'biome', 'check', '--write', file], {
cwd: projectDir,
stdio: 'ignore',
});
} catch {
// 整形に失敗してもツール結果はブロックしない。
}
process.exit(0);

async function readFilePath() {
const raw = await readStdin();
try {
return JSON.parse(raw)?.tool_input?.file_path ?? '';
} catch {
return '';
}
}

function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', () => resolve(''));
});
}
48 changes: 48 additions & 0 deletions .claude/hooks/protect-deps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// PreToolUse(Edit|Write) hook:
// - pnpm-lock.yaml の直接編集をブロック(依存変更は pnpm 経由 → lockfile は自動再生成)。
// - pnpm-workspace.yaml(catalog) / Dockerfile の編集時はネイティブ固定制約の確認を促す(非ブロッキング)。
import { basename } from 'node:path';

const file = await readFilePath();
const name = basename(file);

if (name === 'pnpm-lock.yaml') {
console.error(
'pnpm-lock.yaml は直接編集しないでください。依存は package.json / catalog を変更し、pnpm install で lockfile を再生成してください。',
);
process.exit(2); // ブロック。stderr が Claude に渡る。
}

if (name === 'pnpm-workspace.yaml' || name === 'Dockerfile') {
process.stdout.write(
JSON.stringify({
systemMessage:
'[native-deps-guard] catalog / Dockerfile を編集します。tfjs-node 4.22(N-API v8) / Node22 / avx2+fma / glibc 固定との整合を /native-deps-guard で確認してください。',
}),
);
process.exit(0); // 許可(リマインドのみ)。
}

process.exit(0);

async function readFilePath() {
const raw = await readStdin();
try {
return JSON.parse(raw)?.tool_input?.file_path ?? '';
} catch {
return '';
}
}

function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', () => resolve(''));
});
}
40 changes: 40 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"enabledPlugins": {
"superpowers@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"code-review@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"github@claude-plugins-official": true,
"skill-creator@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true,
"claude-code-setup@claude-plugins-official": true
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-deps.mjs\""
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/biome-format.mjs\""
}
]
}
]
}
}
67 changes: 67 additions & 0 deletions .claude/skills/api-contract-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
name: api-contract-guard
description: detect-images の応答形・エラーコード・HTTP ステータス・エラー優先順位・部分成功不変条件に後方互換を壊す変更が無いかを検査する。PR 前や API 周辺を変更した後に使う。
disable-model-invocation: true
context: fork
allowed-tools: Read, Grep, Glob, Bash
---

# api-contract-guard — 外部消費者(Misskey 本体)向け API 契約の破壊検出

このサービスの応答は Misskey 本体の `judgePrediction`(`FileInfoService.ts` の
`find(x => x.className === 'Sexy' / 'Porn')`)が直接参照する。**形・コード・ステータス・
部分成功の挙動を壊すと本体が無言で誤動作する。** 現在の差分を正本と突き合わせ、破壊的変更を洗い出す。

エンドポイントは **`POST /v1/detect-images`(multipart/form-data・バッチ)** の 1 本。
複数の正規化済み画像を一括推論し、各パーツの生予測値を順序保持で返す。

## 差分(自動注入)

- 変更ファイル一覧: !`git diff --name-only main...HEAD`
- API/契約関連の差分: !`git diff main...HEAD -- apps/server/src packages/core/src README.md`

## 正本(これらを基準に判定する)

- 応答型: `packages/core/src/types.ts`
- `Prediction` = `{ className:string; probability:number }`(nsfwjs 生出力・確率降順。
`PredictionType` を re-export しつつ className を string に緩めた契約)。
- `BatchItemResult` = `{ success:true; predictions: Prediction[] }`
| `{ success:false; error:{ code:DetectErrorCode; message:string } }`(パーツ単位の結果)。
- `DetectImageSuccessResult` / `DetectFailedResult` は core 内部の単一推論型(`detectImage`)であり、
**HTTP 応答の形ではない**点に注意。
- **HTTP 応答形**(`apps/server/src/routes/detect-images.ts` / `app.ts`):
- 成功(200)= `{ success:true, result:{ results: BatchItemResult[] } }`。
`results` の順序はリクエストパーツ順と一致する。
- リクエスト全体の失敗(4xx)= `{ success:false, error:{ code, message } }`。
- エラーコード: `DetectErrorCode`(7 種)と HTTP ステータス表 `STATUS_BY_CODE`
(`apps/server/src/lib/error-mapping.ts`)。7 種 =
`AUTHENTICATION_REQUIRED`(401) / `INVALID_REQUEST`(400) / `UNSUPPORTED_MEDIA_TYPE`(415) /
`REQUEST_TOO_LARGE`(413) / `IMAGE_DECODE_FAILED`(422) / `MODEL_UNAVAILABLE`(503) /
`DETECTION_FAILED`(500)。
- **リクエスト全体の優先順位(先勝ち。`app.ts` のミドルウェア順 + `detect-images.ts`)**:
認証(401) → bodyLimit 超過 413(`maxBodySize`) → 非 multipart 415 →
multipart パース失敗 400 → パーツ 0 件 400 → パーツ数超過 413(`maxParts`)。
- **パーツ単位の検査順(`detect-images.ts` 内、各パーツ毎・全体は 200 のまま `results[i].error` に格納)**:
非 File 400 → 非対応 part Content-Type 415 → 空ボディ 400 → サイズ超過 413(`maxBinarySize`) →
dimensions 読取失敗 422(`IMAGE_DECODE_FAILED`) → dimensions 上限超過 413
(`maxImageWidth`/`maxImageHeight`/`maxImagePixels`) → 推論。
- 受理する **パーツの** Content-Type: `image/png|jpeg|gif|bmp`
(`detect-images.ts` の `ACCEPTED_CONTENT_TYPES`)。リクエスト全体は `multipart/form-data`。
- `README.md` の API 表。

## 破壊的変更とみなすもの(❌ で報告)

- `result.results[]` 構造の変更・`BatchItemResult` のキー改名・`predictions` の形変更・
確率降順保証の喪失。
- **部分成功不変条件の破壊**: パーツ単位の失敗で全体を 4xx にする、`results` の順序保証の喪失、
パーツ失敗を 200 以外で返す。
- `DetectErrorCode` の削除・改名、`STATUS_BY_CODE` のステータス変更。
- リクエスト全体/パーツ単位いずれかのエラー優先順位の入れ替え。
- `success` フラグの廃止・意味変更、`error.code` 以外での分岐を強要する変更。
- 受理する Content-Type(リクエスト=multipart / パーツ=image/*)の縮小。

## 報告

各論点を ✅(影響なし) / ⚠️(要確認) / ❌(後方互換破壊) で列挙し、❌ は
「どのファイルのどの変更が・本体の何を壊すか・どう直すか」まで一行で示す。
**新規エンドポイントの追加など既存契約を変えない拡張は ✅**(scope-guard の判断軸に従う)。
43 changes: 43 additions & 0 deletions .claude/skills/native-deps-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
name: native-deps-guard
description: tfjs-node / nsfwjs / Node / Docker ベースイメージなどネイティブ ML スタック依存を上げる前に、N-API v8・avx2+fma・glibc・Node22 固定との整合を確認する。依存のバージョンを上げる時に使う。
disable-model-invocation: true
allowed-tools: Read, Grep, Bash, WebFetch
---

# native-deps-guard — ネイティブ ML スタック依存の互換チェック

対象依存(指定があれば $ARGUMENTS、無ければ下記すべて)の更新可否を、このリポジトリの
固定制約に照らして判定する。**結論(上げてよい / だめ / 条件付き)と根拠を報告するだけ。
勝手にバージョンを書き換えない。**

## 固定されている制約(根拠)

- **Node 22 系に固定**。`@tensorflow/tfjs-node@4.22.0` の配布バイナリは N-API v8 まで前提で、
Node 24+ のビルド/実行互換は未保証([Dockerfile](../../../Dockerfile) 冒頭コメント /
ルート [package.json](../../../package.json) の `engines.node >= 22`)。
- **x64 avx2+fma 必須・glibc 依存**(libtensorflow バイナリの制約)。Alpine(musl) は不可、
ベースは `node:22-bookworm-slim`(glibc 2.36)。
- バージョンは **[pnpm-workspace.yaml](../../../pnpm-workspace.yaml) の catalog 1 箇所**で集中管理。
`@tensorflow/tfjs` と `@tensorflow/tfjs-node` は **必ず同じ版に揃える**。
- ネイティブビルド許可は同ファイルの `onlyBuiltDependencies` にある
(`@tensorflow/tfjs-node` を外すとビルドが走らない)。

## 手順

1. 現状把握: `pnpm-workspace.yaml`(catalog)・各 `package.json`・`Dockerfile` を読む。
2. 上げたい依存の **リリースノート / CHANGELOG / peerDependencies** を WebFetch で確認する:
- **tfjs-node**: N-API ターゲット、対応 Node、libtensorflow の版と CPU 命令要件。
- **nsfwjs**: 要求する `@tensorflow/tfjs(-node)` の版(tfjs と歩調を合わせる)。
- **Node engines / Docker ベース**: tfjs-node が対応する Node 範囲を出ない glibc イメージか。
3. 判定観点:
- N-API/Node 互換は壊れないか(Node 24+ は tfjs-node が N-API v9+ バイナリを配布してから)。
- avx2+fma / glibc 前提を崩していないか。
- tfjs と tfjs-node の版が一致しているか。
4. **報告**: 各依存を `✅ 上げてOK / ⚠️ 条件付き / ❌ 非推奨` + 一行根拠 +
実施するなら「catalog のどの行をどう変える / 何を検証する」まで。

## 上げた後の検証(案内のみ。実行は `/preflight`)

`pnpm install` → `pnpm run build` → `pnpm run test:integration`
(CPU が avx2+fma 非対応 or モデル不在なら統合テストは skip される)→ `docker build .`。
32 changes: 32 additions & 0 deletions .claude/skills/preflight/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: preflight
description: PR・コミット前に CI 相当のローカル関門(typecheck / lint / unit / integration / docker build)を順に通し、落ちた所だけ報告する。CI が未整備の間の関門であり、のちの CI 仕様でもある。
disable-model-invocation: true
allowed-tools: Bash, Read
---

# preflight — ローカル関門(CI 相当)

リポジトリルートで以下を順に実行する。**途中で落ちても残りは続行**し、最後に
「どこが緑/赤か」を一覧にする。すべて緑なら「PR 可」と明言する。

1. `pnpm run typecheck` — core を build してから各 package を `tsc --noEmit`。
2. `pnpm run lint` — `biome check .`。
3. `pnpm run test:unit` — 純粋ロジック。
4. `pnpm run test:integration` — 実モデルロード+実 classify。
**CPU が avx2+fma 非対応 or モデル不在なら自動 skip** される(その場合は「skip」と明示し、緑とは区別する)。
モデルは環境変数 `SENSITIVE_DETECTOR_TEST_MODEL_DIR` で上書き可。
5. `docker build -t sensitive-detector:preflight .` — ネイティブビルド(node-gyp / tfjs-node)が通るか。
**イメージの push はしない**(レジストリ publish は予定なし)。一番遅いステップなので最後に置く。

## 報告フォーマット

| step | 結果 |
| --- | --- |
| typecheck | ✅ / ❌ |
| lint | ✅ / ❌ |
| test:unit | ✅ / ❌ |
| test:integration | ✅ / ❌ / ⏭ skip |
| docker build | ✅ / ❌ |

赤があれば、そのコマンドの末尾出力から原因行を引用し、最小の修正方針を添える。
49 changes: 49 additions & 0 deletions .claude/skills/scope-guard/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: scope-guard
description: sensitive-detector に機能を足す時の判断軸と、生予測値サービスとしての不変条件。コードを書く・レビューする・新機能を検討する時に常に適用する。
user-invocable: false
---

# scope-guard — sensitive-detector のスコープ判断と不変条件

このサービスは Misskey の `AiService.detectSensitive`(nsfwjs + @tensorflow/tfjs-node 推論)を
HTTP サイドカーに切り出したもの。切り出して嬉しいのは **ネイティブ ML スタック
(tfjs-node / libtensorflow、モデルのメモリ常駐、x64 avx2+fma 制約、glibc 依存)の隔離** だけ。

## 機能を足してよいかの判断軸(エンドポイント数は不変条件ではない)

新機能・新エンドポイントを検討する時は、まず次を問う:

1. **ネイティブ ML スタックの隔離に必要か?** 必要なら足してよい(例: 将来の detect-file 構想、
モデル情報の公開など)。「`/v1/detect-images` 1 本のみ」は *現状の数* であって固定ルールではない。
2. **「正規化済み入力 → 推論 → 生予測値」に徹しているか?**
3. **正規化・しきい値・集約を持ち込んでいないか?**

「便利そう」「ついでに」で 2 / 3 を侵すものは足さない。

## 守る不変条件(数とは無関係に常に成立させる)

- 返すのは **推論の生予測値だけ**。`sensitive` / `porn` のしきい値判定・フレーム集約・
per-user ポリシーは **Misskey 本体(`FileInfoService.ts` の `judgePrediction`)に残す**。ここには入れない。
- 受け取るのは **正規化済み画像バイト**。画像正規化(sharp の resize/rotate/flatten/PNG 変換)と
動画・APNG のフレーム抽出(ffmpeg)は本体に残す。**v1 では sharp / fluent-ffmpeg / ffmpeg を依存に足さない。**
- **物理パス入力・mediaDir・ディレクトリトラバーサル防御・JSON 入力スキーマ** は持ち込まない
(入力は画像バイナリ本体)。
- 予測値の形は nsfwjs の生出力(全クラス・確率降順)。`predictions[].className` は本体が
`find(x => x.className === 'Sexy')` で引ける契約([packages/core/src/types.ts](../../../packages/core/src/types.ts) の `Prediction`)。
- **HTTP 応答はバッチ形**。成功は `{ success:true, result:{ results: BatchItemResult[] } }`
(パーツ順保持)。`BatchItemResult` はパーツ毎に `{ success:true, predictions }` か
`{ success:false, error:{ code, message } }`。**パーツ単位の失敗で全体を 4xx にしない(部分成功は常に 200)**。
- **エラーは throw せず** `{ success:false, error:{ code } }` を返す([packages/core/src/errors.ts](../../../packages/core/src/errors.ts))。
`code` は `DetectErrorCode` の 7 種のみ。`message` は診断用で API 契約上の意味を持たない。
- リクエスト全体のエラー優先順位(先勝ち): **認証(401) → bodyLimit 413 → 非 multipart 415 →
multipart パース失敗/パーツ 0 件 400 → パーツ数超過 413**。認証と bodyLimit は手前のミドルウェアで処理済み。
パーツ単位の検査(非対応 Content-Type 415 / 空 400 / サイズ・dimensions 超過 413 / decode 失敗 422 / 推論)は
`results[i].error` に格納し全体は 200。詳細な突合は `/api-contract-guard`。

## 迷ったら

[README.md](../../../README.md) の「Misskey 本体との統合」節(本体に残す責務 = 正規化・フレーム抽出・
`judgePrediction`・集約)を確認する。そこに隔離済みのもの・本体に残すものは v1 では着手しない。
契約(応答形・エラーコード)に触れる変更は `/api-contract-guard` で後方互換を確認する。
ネイティブ依存を上げる変更は `/native-deps-guard` で互換を確認する。
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
.env
.env.*
!.env.example
node_modules
**/node_modules
**/dist
.git
.gitignore
.pnpm-store
coverage
**/*.tsbuildinfo
*.log
.agents
.claude
要件定義.md
README.md
config.dev.*
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# compose.yml が読み込む環境変数の見本。`cp .env.example .env` して値を埋める。
# .env は .gitignore 済み(コミットしない)。

# 静的な共有シークレット(Bearer token)。十分長いランダム値にすること。
# 例: openssl rand -hex 32
# 未設定 / 空のままだと起動時に ConfigError で落ちる(TCP 待ち受けでは apiKey 必須)。
SENSITIVE_DETECTOR_API_KEY=

# ホスト側で公開するポート(コンテナ内は固定で 3009)。
# Misskey 本体が 3000 を使うので、ホスト側は 3009 にずらしておく。
HOST_PORT=3009
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,8 @@ dist
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

# Local dev config (contains machine-specific paths)
config.dev.mjs
config.dev.cjs
config.local.*
Loading
Loading