Still On The Wall
← 一覧へ戻る
IT

ハーネスまだまだ改善

ハーネスって何?

Claude Code でブログを作ってから、記事投稿もコード修正もほぼエージェント任せになっている。 ここでいう「ハーネス」とは、自分が組んだエージェント構成のことだ。

  • planner — 要件を読んでスプリント仕様を生成する
  • generator — 仕様を読んで実装する
  • evaluator — Playwright で実際に動かしてテスト・評価する

クライミングのハーネスと同じで、これがちゃんと機能していないと墜落する。

何が問題だったか

generator が吐き出す page.tsx が肥大化していた。 データフェッチ・UI・ロジックが1ファイルに混在していて、 100行を超えるコンポーネントが平気で生まれていた。

たとえばカテゴリページはこんな状態だった。

// app/categories/[slug]/page.tsx(修正前のイメージ)
export default async function CategoryPage({ params }) {
  const category = await prisma.category.findUnique({ ... });
  if (!category) notFound();

  return (
    <div className="max-w-4xl mx-auto px-6 py-12">
      <h1>{category.name}</h1>
      {category.articles.map((article) => (   // ← ここで any 型警告
        <div key={article.id}>
          <a href={`/posts/${article.slug}`}>{article.title}</a>
          {/* ...50行のJSXが続く... */}
        </div>
      ))}
    </div>
  );
}

問題は2つある。

  1. コンポーネントが巨大
    UIの変更のたびに page.tsx を触る羽目になる。Server Component と Client Component の境界も曖昧になる。

  2. TypeScript の暗黙的 any
    articles.map((article) => ...)articleany になる。 Prisma のクエリ結果は型が取れるはずなのに、もったいない。

どう直したか

① page.tsx はデータフェッチのみに絞る

// app/categories/[slug]/page.tsx(修正後)
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import CategoryView from "./CategoryView";

export default async function CategoryPage({ params }) {
  const { slug } = await params;
  const category = await prisma.category.findUnique({
    where: { slug },
    include: { articles: { where: { status: "PUBLISHED" }, ... } },
  });
  if (!category) notFound();

  return <CategoryView category={category} />;
}

UIは CategoryView.tsx に切り出した。 page.tsx は「何を取ってくるか」だけを知っている状態になった。

② Prisma の型を正しく使う

// CategoryView.tsx
import type { Prisma } from "@prisma/client";

type CategoryWithArticles = Prisma.CategoryGetPayload<{
  include: { articles: { include: { category: true } } };
}>;

export default function CategoryView({ category }: { category: CategoryWithArticles }) {
  return (
    <>
      {category.articles.map((article) => (  // article は型推論が効く
        <ArticleCard key={article.id} article={article} />
      ))}
    </>
  );
}

Prisma.XxxGetPayload<{...}> を使えば、include の構造まで含めた型が取れる。 暗黙的 any はゼロになった。

③ ESLint エラーも一緒に潰す

DarkModeToggleuseEffect 内で setState を呼んでいた。

// 修正前: useEffect 内で直接 setState
useEffect(() => {
  const isDark = ...; 
  setDark(isDark);  // ← ESLint エラー
  document.documentElement.classList.toggle("dark", isDark);
}, []);

// 修正後: useState の遅延初期化で解決
const [dark, setDark] = useState(getInitialDark);  // 初回のみ関数を実行

useEffect(() => {
  document.documentElement.classList.toggle("dark", dark);  // DOM同期のみ
}, [dark]);

useEffect の中で setState を呼ぶと再レンダーのカスケードが起きる。 useState の遅延初期化(関数を渡す形式)に移すと、マウント時に1回だけ実行されてきれいに収まる。

ハーネス自体も直す

コードを直しただけでは次のスプリントでまた同じ問題が起きる。 generator.mdevaluator.md にルールを明文化した。

generator.md に追加したこと:

  • page.tsx はコンポーネント呼び出しのみ(JSX直書き禁止)
  • 引き渡し前に Docker Node 20 内で tsc --noEmit + ESLint を実行する

evaluator.md に追加したこと:

  • 型安全性チェックを評価基準に追加(閾値5 = 必須)
  • tsc と ESLint のチェックコマンドを明記

ルールが仕様書に書いてあっても、エージェントが自分でチェックできないと意味がない。 「引き渡し前に自分でチェックして、ゼロでなければその場で直す」という手順を組み込んだ。

やってみて

エージェントに「コンポーネント分割して」と指示すると、それなりに分割してくれる。 ただし粒度の基準がなければ、1コンポーネント200行のものが出てくることもある。

ルールを言語で定義して、チェックを自動化する。 これがハーネスを締め直すということだと思っている。

クライミングのハーネスもずっと同じものを使い続けると、気づかないうちに劣化する。 コードのハーネスも同じで、定期的に見直さないとどこかで落ちる。

まだまだ改善の余地はある。次は e2e テストをコミット前フックに組み込みたい。