frourio Framework — Code Review
frourio (Fastify + aspida + zod) ベース TypeScript フルスタックフレームワーク採用プロジェクトの PR / 差分レビュー観点を集約。 違反検出 → 指摘必須。1指摘1行 (位置・問題・修正)。
実装規約 (skill: frourio-framework) の鏡像。実装時規約 vs レビュー時検出観点で役割分離。
0. レビュー優先度
- 🔴 Critical — 型安全性・セキュリティ・自動生成破壊。即修正
- 🟡 Warning — 規約逸脱・保守性低下。修正推奨
- 🔵 Info — スタイル・命名。任意
1. ディレクトリ・ファイル配置 (🔴 Critical)
- 新規ルートに
index.ts(Methods 型) 存在 - 新規ルートに
controller.ts(defineController) 存在 - 動的セグメント (
_<name>@<type>/) 含むルートにvalidators.ts存在 - 動的セグメント命名:
_<name>@stringor_<name>@numberのみ (他型禁止) - ルートディレクトリ名と URL マッピングが意図通り
検出パターン:
api/foo/_id/(型指定無し) →_id@stringor_id@numberに修正api/foo/[id]/(Next.js 風) → frourio 規約違反api/foo/:id/(Express 風) → frourio 規約違反
検出 grep:
# 型指定無し動的セグメント
find api -type d -name '_*' | grep -v '@string\|@number'
# Next.js 風 / Express 風
find api -type d \( -name '\[*\]' -o -name ':*' \)
2. 自動生成ファイル編集 (🔴 Critical)
以下が diff に含まれる → 即 reject:
$server.ts(プロジェクトルート)- 各ルートの
$relay.ts api/$api.ts__generated__/配下全て (Prisma model + repository 生成物)
検出 grep:
git diff --name-only origin/main...HEAD | grep -E '(\$server\.ts|\$relay\.ts|\$api\.ts|__generated__/)'
修正方針: 該当ファイル変更を revert → schema / index.ts 等の 入力側 を修正 → frourio / prisma generate 再実行で再生成。
3. controller.ts (🔴 Critical)
-
export default defineController(...)形式のみ (素の関数 export 禁止) - handler 引数 (
query/params/body/headers) はindex.tsMethods 定義と一致 - handler 戻り値 =
{ status, body, headers? }シェイプ - DI 利用時は
defineController({ ...deps }, ({ deps }, fastify) => ({...}))overload - async handler の throw → Fastify error handler でキャッチ可能か確認
違反例:
// ❌ 素の関数 export
export default async (req) => ({ status: 200, body: {} });
// ❌ status 欠落
get: () => ({ body: {} }),
// ❌ index.ts に未定義の query 参照
get: ({ query }) => { const x = query.foo; ... }, // Methods に foo 無し
検出 grep:
# defineController 経由でない default export
grep -rL 'defineController' api/**/controller.ts
4. API 応答 — DTO 変換 (🔴 Critical)
最重要: API レスポンスは必ず <Model>Model.toDto() 経由。
検出パターン (NG):
// ❌ Prisma 生値直接返却 — Date 型残留 → JSON シリアライズで型不整合
const user = await prisma.user.findUnique({ where: { id } });
return { status: 200, body: user };
// ❌ Model インスタンス直接返却 — private field 露出 + Date 残留
const user = UserModel.fromPrismaValue({ self: prismaUser });
return { status: 200, body: user };
// ❌ 手動 spread — toDto() の DTO 変換ロジック迂回
return { status: 200, body: { id: user.id, name: user.name } };
// ❌ 手動 omit (password 等) — @dto(hidden) で schema 側に寄せる
return { status: 200, body: { ...user.toDto(), password: undefined } };
OK パターン:
// ✅ toDto() 経由
return { status: 200, body: user.toDto() };
// ✅ 用途別 DTO
return { status: 200, body: user.toPublicDto() };
// ✅ 配列も map で
return { status: 200, body: users.map(u => u.toDto()) };
検出 grep:
# controller 内で prisma 直叩き → 即返却の臭い
grep -rn 'prisma\.\w\+\.\(findUnique\|findFirst\|findMany\|create\|update\)' api/**/controller.ts
# return body: <prisma変数> パターン
grep -rn 'body:\s*\(await\|prisma\)' api/**/controller.ts
5. Prisma → Model 変換 (🔴 Critical)
-
<Model>Model.fromPrismaValue({ self, ...includes })または<Model>Model.builder().fromPrisma(...).<rel>(...).build()で変換 -
new <Model>Model(...)直接呼び禁止 (型変換ロジック迂回) -
fromPrismaValue利用時は relation 全指定 (欠落 → ランタイムエラー) - Builder 利用時の必須 field 設定漏れチェック
違反例:
// ❌ 直接 new
const user = new UserModel({ id, name, ... });
// ❌ fromPrismaValue で relation 欠落 (UserModel が posts/books 必須なのに self のみ)
const user = UserModel.fromPrismaValue({ self: prismaUser });
検出 grep:
grep -rn 'new \w\+Model(' src/ usecase/ api/
6. index.ts — 型定義 (🔴 Critical)
-
resBodyは生成<Model>ModelDto型を import 使用 (自前再定義禁止) -
MethodsはDefineMethods<{...}>で wrap - query / reqBody / resBody 型が controller 実装と一致
違反例:
// ❌ 自前型定義 — 生成 DTO とドリフト
export type Methods = DefineMethods<{
get: { resBody: { id: number; name: string } }; // UserModelDto を使わない
}>;
// ✅
import type { UserModelDto } from '<shared-types>';
export type Methods = DefineMethods<{ get: { resBody: UserModelDto } }>;
検出 grep:
# resBody に inline object — DTO 型未使用の臭い
grep -rn 'resBody:\s*{' api/**/index.ts | grep -v 'ModelDto\|Dto\b'
7. validators.ts (🟡 Warning)
- 動的セグメント全 param が zod schema に含まれる
-
_id@number→z.coerce.number()orz.number()(frourio 側で string→number 変換確認) -
_id@string→z.string()+ 必要なら.uuid()/.regex()等の追加検証 - reqBody / query 検証も必要なら追加 (信用境界)
検出 grep:
# 動的セグメント有り → validators.ts 欠落
for d in $(find api -type d -name '_*'); do
parent=$(dirname "$d")
[ -f "$parent/validators.ts" ] || echo "missing validators: $parent"
done
8. hooks.ts — auth (🔴 Critical)
- 認証必須ルート →
hooks.tsでonRequestmiddleware 注入 - 親ディレクトリ
hooks.tsでの auth 注入 → 子孫ルートで重複定義不要 (継承確認) -
req.user等のカスタム request property →types/fastify.d.tsでdeclare module 'fastify'拡張済み - auth 失敗時
reply.code(401).send(...)で early return - controller 内で auth check しない (hooks に分離)
- 認証必須範囲 (例:
api/admin/配下) のcontroller.tsは、自身 or 親階層いずれかにhooks.ts必須 — 自身も親も無い → 認証バイパス脆弱性 (🔴 即修正)- 例外: token 発行 endpoint (
api/admin/auth/login等) は意図的に親 hooks 適用範囲から外す
- 例外: token 発行 endpoint (
違反例:
// ❌ controller 内で auth check (hooks に分離すべき)
get: async (req) => {
if (!req.headers.authorization) return { status: 401, body: {...} };
...
}
// ❌ req.user 型未拡張 → controller で型エラー or any 化
req.user = await req.jwtVerify(); // FastifyRequest に user property なし
// ❌ 認証範囲の hooks.ts 配置漏れ → バイパス
// api/admin/sync/controller.ts 存在、api/admin/sync/hooks.ts 無し、
// 親 api/admin/hooks.ts も無し (admin/auth/login のため共通祖先には置けない)
// → 認証ゼロで sync trigger 叩ける = 脆弱性
検出 grep:
# controller 内 auth check (hooks 漏れ)
grep -rn 'headers\.\(authorization\|cookie\)' api/**/controller.ts
grep -rn 'jwtVerify\|verifyToken' api/**/controller.ts
# 認証必須範囲 (admin 配下) で controller.ts に hooks.ts 継承無し検出
# 自身ディレクトリと祖先全部を遡って hooks.ts 探索 → 1個も無ければ警告
for f in $(find api/admin -name controller.ts); do
dir=$(dirname "$f")
found=""
while [ "$dir" != "api" ] && [ "$dir" != "." ] && [ "$dir" != "/" ]; do
if [ -f "$dir/hooks.ts" ]; then found=1; break; fi
dir=$(dirname "$dir")
done
[ -z "$found" ] && echo "AUTH BYPASS RISK: $f"
done
# login / callback 等 token 発行 endpoint は除外 (意図的バイパス)
9. hooks 重複 — middleware 切り出し (🟡 Warning)
- 兄弟
hooks.ts間で同一ロジックのコピペ無し - 共通認証 / rate limit / logging は
backend-api/middleware/<name>.tsに集約 - 各
hooks.tsは import +defineHooks(() => ({ onRequest: <imported fn> }))の薄ラッパに留まる - 認証必須範囲の 共通祖先 に
hooks.tsを 1つだけ配置 (子孫ごと個別禁止) - 認証範囲が異なる兄弟 (例:
auth/login等) は共通祖先より下に分離 (frourio hooks は重ねがけ・override 不可) - middleware は
(req: FastifyRequest, reply: FastifyReply) => Promise<void>形式に統一 (Fastify インスタンス必要時はreq.server経由 — 例:req.server.jwt.verify(token)) - 子孫に既に hooks 継承されているのに、子側で重ねて同じ middleware を再注入しない (二重実行 → reply 二度送信エラー)
9.1 配置パターン早見表
認証範囲とディレクトリ構成の典型パターン:
| 構成 | hooks.ts 配置 | 理由 |
|---|---|---|
admin/ 配下全部に同一認証 + auth/login のみ token 発行 |
各サブ範囲ごと: admin/clients/hooks.ts, admin/report/hooks.ts, admin/sync/hooks.ts (login は親に置けないので) |
admin/hooks.ts 親集約 → login も認証必須化 → token発行不能 |
admin/ 配下全部に同一認証 + login 系無し |
admin/hooks.ts 1個のみ |
共通祖先1個で全子孫継承 |
| ルート別に異なる scope (admin vs user) | admin/hooks.ts (admin scope) + user/hooks.ts (user scope) |
scope ごとに祖先分離 |
| 一部だけ追加 hook (rate limit 等) | 既存 auth hooks.ts より下の階層に新規 hooks.ts (auth は祖先継承、rate limit は当該階層追加) | hooks 重ねがけ |
違反例:
// ❌ api/admin/clients/.../tiktok/hooks.ts と api/admin/report/hooks.ts に
// 同一の認証コード 60 行コピペ
// ❌ 親 admin/clients/hooks.ts で auth 適用済みなのに
// 子 admin/clients/_id@string/tiktok/hooks.ts で同じ authAdminMiddleware 再注入
// → 二重実行で reply 二度送信 (FST_ERR_REP_ALREADY_SENT)
OK パターン:
// middleware/authAdminMiddleware.ts — 1箇所に集約 (fastify インスタンス引数なし)
import type { FastifyRequest, FastifyReply } from 'fastify';
export const authAdminMiddleware = async (req: FastifyRequest, reply: FastifyReply) => {
const payload = await req.server.jwt.verify(token); // req.server で fastify 取得
if (!hasAdminScope(payload)) { reply.code(403).send(...); return reply; }
};
// api/admin/clients/hooks.ts — 共通祖先に 1つだけ (tiktok/, _id@string/ 子孫全部に継承)
import { defineHooks } from './$relay';
import { authAdminMiddleware } from '$/middleware/authAdminMiddleware';
export default defineHooks(() => ({ onRequest: authAdminMiddleware }));
検出 grep:
# hooks.ts ファイル内の行数が多い → middleware 切り出し漏れの臭い (薄ラッパなら 5行前後)
find api -name 'hooks.ts' -exec wc -l {} \; | awk '$1 > 15'
# 同一 import パターンの兄弟 hooks.ts カウント
grep -rh '^import' api/**/hooks.ts | sort | uniq -c | sort -rn | head
# 同一 middleware を親子両方の hooks.ts で onRequest 注入 → 二重実行検出
for f in $(find api -name hooks.ts); do
dir=$(dirname "$f")
imports=$(grep -oE 'from .+/middleware/\w+' "$f" | sort -u)
parent=$(dirname "$dir")
while [ "$parent" != "api" ] && [ "$parent" != "." ] && [ "$parent" != "/" ]; do
if [ -f "$parent/hooks.ts" ]; then
for imp in $imports; do
if grep -q "$imp" "$parent/hooks.ts" 2>/dev/null; then
echo "DUPLICATE INJECTION: $f also injects $imp already from $parent/hooks.ts"
fi
done
fi
parent=$(dirname "$parent")
done
done
# defineHooks の中身が短い (薄ラッパ) か診断
grep -lE 'defineHooks\(\(\) => \(\{ onRequest: \w+ \}\)\)' api/**/hooks.ts
10. Prisma Schema — DTO アノテーション (🔴 Critical)
- センシティブ field (
password/apiKey/secret/token等) は/// @dto(hidden: true)付与 - 用途別 DTO は
/// @dto.profile(name: X, pick/omit: [...])で生成 (controller で自前選別禁止) -
Jsonfield の独自型は/// @json(type: [TypeName])+ generator のadditionalTypePath設定 - relation を nested DTO 化したい場合は
/// @dto(nested: true) -
@dto.profileでpickとomit同時指定無し (排他)
違反例:
// ❌ password が hidden 指定無し → toDto() で漏洩
model User {
password String
}
// ❌ controller で手動 omit (自前ロジック)
return { status: 200, body: { ...user.toDto(), password: undefined } };
検出 grep:
# センシティブ field 名 + @dto(hidden) 欠落
grep -nE '^\s+(password|apiKey|secret|token|refreshToken)\s+String' schema.prisma | \
grep -v '@dto(hidden'
11. Prisma バージョン整合 (🔴 Critical)
-
prismaと@prisma/clientが 同一バージョン、かつ >= 7.2.0 -
package.json差分でバージョンずれ検出時は指摘 - lockfile (
package-lock.json/pnpm-lock.yaml) も整合
検出 bash:
node -e "
const p = require('./package.json');
const a = (p.dependencies||{})['@prisma/client'];
const b = (p.devDependencies||{})['prisma'] || (p.dependencies||{})['prisma'];
if (a !== b) console.log('VERSION MISMATCH:', { '@prisma/client': a, prisma: b });
"
12. 共有型エクスポート (🟡 Warning)
- 新規 Model 追加 → 共有 types エントリ (例:
shared-types/models.ts) に<Model>ModelDto(+ profile DTO) を re-export 追加 - frontend / backend 双方が同一 import path で DTO 型参照
-
__generated__/配下を frontend が直 import していない (共有 entry 経由)
検出 grep:
# frontend 側で __generated__ 直 import
grep -rn '__generated__' frontend/src/ web/src/
13. フロントエンド — データ取得 (🔴 Critical)
- データ取得は
useFrourioSWRを使用 -
useAspidaSWR新規利用 →useFrourioSWRに置換指摘 (legacy fallback) -
useEffect+fetch/aspida client直叩き禁止 - 条件付き fetch は
useFrourioSWR(condition ? endpoint : null)パターン - 依存値変化での再取得は query / params 経由 (キャッシュキー自動更新)
- mutation 後の再取得は
mutate()経由 -
useState+useEffectで fetch 結果を保持していないか
違反例:
// ❌ useEffect でデータ取得
useEffect(() => {
adminApiClient.admin.users.$get().then(setUsers);
}, []);
// ❌ 自前 enable フラグ + useEffect
const [data, setData] = useState();
useEffect(() => {
if (!id) return;
adminApiClient.admin.tenants._tenantId(id).$get().then(setData);
}, [id]);
// ❌ useAspidaSWR 新規利用 (legacy)
const { data } = useAspidaSWR(adminApiClient.admin.users);
OK パターン:
// ✅ シンプル GET
const { data } = useFrourioSWR(adminApiClient.admin.users);
// ✅ 条件付き
const { data } = useFrourioSWR(
id ? adminApiClient.admin.tenants._tenantId(id) : null,
);
// ✅ query
const { data } = useFrourioSWR(adminApiClient.hq.projects, {
query: { status: 'IN_PROGRESS' },
});
検出 grep:
# useEffect 内で aspida client / fetch
grep -rnB1 -A5 'useEffect' frontend/src/ | grep -E '(\.\$get\(|\.\$post\(|fetch\()'
# useAspidaSWR 新規利用
grep -rn 'useAspidaSWR' frontend/src/
14. 生成コマンド実行漏れ (🟡 Warning)
-
schema.prisma変更 PR →prisma generate実行済み (__generated__/差分含む) - 新規ルート / Methods 型変更 PR → frourio 生成実行済み (
$server.ts/$relay.ts整合) - aspida 型変更 →
$api.ts整合
検出 bash:
# schema 変更されたが __generated__ 未更新
git diff --name-only origin/main...HEAD | grep -q 'schema\.prisma' && \
! git diff --name-only origin/main...HEAD | grep -q '__generated__/' && \
echo "WARN: schema 変更あり、__generated__ 未更新 → prisma generate 漏れ"
# index.ts 変更あるが $server.ts / $relay.ts 未更新
git diff --name-only origin/main...HEAD | grep -q 'api/.*/index\.ts' && \
! git diff --name-only origin/main...HEAD | grep -qE '\$server\.ts|\$relay\.ts' && \
echo "WARN: index.ts 変更あり、生成物未更新 → frourio 生成漏れの可能性"
15. 型エラー / ビルド (🔴 Critical)
-
tsc --noEmitパス -
npm run generate実行後の生成物と diff 整合 (生成漏れ検出) - CI / lint pass
検出 bash:
npx tsc --noEmit
npm run generate && git diff --exit-code # 生成物 drift 検出
16. レビュー実行手順
git diff --name-only origin/main...HEADで差分ファイル一覧- セクション 2 (自動生成ファイル編集) — 即 reject 判定
- セクション 1, 3-6, 8, 10, 11, 13, 15 (🔴 Critical) — 全数チェック
- セクション 7, 9, 12, 14 (🟡 Warning) — サンプリング or 全数
- 検出 → genshijin-review 形式で出力
レビュー出力フォーマット (genshijin-review 互換)
[file:line] 🔴/🟡/🔵 [問題]: [修正案]
例:
api/users/_id@string/controller.ts:12 🔴 Prisma 生値直接返却: user.toDto() に変更
api/users/index.ts:5 🔴 自前 resBody 型定義: UserModelDto を import 使用
prisma/schema.prisma:34 🔴 password に @dto(hidden: true) 欠落: コメント追加
frontend/pages/users.tsx:18 🔴 useEffect でデータ取得: useFrourioSWR に置換
$server.ts:1 🔴 自動生成ファイル編集: 元に戻し frourio CLI 再実行
api/admin/report/hooks.ts:5-65 🟡 hooks ロジックコピペ: middleware/authAdminMiddleware に切り出し
api/foo/_id/index.ts 🔴 動的セグメント型指定欠落: _id@string or _id@number に rename
package.json:12 🔴 prisma バージョン不整合: prisma と @prisma/client を同一バージョン (>= 7.2.0) に揃える
関連 skill
- 実装規約:
frourio-frameworkskill (skills/frourio-framework/SKILL.md) - 実装規約 (rule 形式):
rules/frourio-framework-activate.md - レビュー規約 (rule 形式):
rules/frourio-framework-review.md