リンドくんリンドくん

たなべさん、解析ツールで自分の Go の CLI を読ませたら「デプロイ先: Vercel」って出たんですけど、そんな設定ファイル1つも置いてないんですよ。

たなべたなべ

……それ、うちが出荷したバグだね。ごめん。
しかも同じ性質のバグを2回出している。今日はその話をしようか。

リンドくんリンドくん

2回もですか。

たなべたなべ

うん。原因は「難しい判定を間違えた」じゃないんだ。推測値を、検出結果とまったく同じ形で返していた。これだけ。

出荷したバグの話から始めます

FRKZ のリポジトリ解析は、GitHub のリポジトリを読んで、言語・パッケージマネージャ・フレームワーク・コマンドを検出します。そこで同じ性質のバグを2回出荷しました

1回目。デプロイ先の検出が、何も見つからないときにプロジェクトスターターの既定値を返していました。結果として、デプロイ設定を1行も持たない Go の CLI が「デプロイ先: Vercel」と表示されました。

2回目。コマンドの検出が、リポジトリにスクリプトが無いときフレームワークカタログの既定値を返していました。結果として、テストスクリプトを持たないリポジトリの AGENTS.md に pnpm test が書き込まれました。エージェントはそれを実行し、失敗します。

共通しているもの

どちらも「裏付けの無い値が、リポジトリから読んだ値とまったく同じ形で返っていた」という1つの誤りです。型も同じ、見た目も同じ。区別する手段が、どこにもありませんでした。

なぜ気づけないのか

リンドくんリンドくん

でも、テストしていれば気づきそうなものですけど……。

たなべたなべ

それが厄介なところで、このバグは一度もエラーを出さないんだ。
しかも、たいていの場合は当たっている。当たっている間は、誰も疑わない。

このバグには、気づきにくい理由が3つあります。

エラーにならない。 推測値は文字列として正しく、スキーマも通ります。落ちるところがありません。

もっともらしい。 「Next.js プロジェクトのビルドは pnpm build」は、たいてい当たります。当たっている間は誰も疑いません。

失敗が遠い。 誤りが表面化するのは、生成された AGENTS.md を読んだエージェントが、存在しないコマンドを実行した時点です。ツールの画面からは遠く離れた場所で、しかも「エージェントが変なことをした」という形で現れます。

「出所」を値に持たせる

最初の修正は、コマンドに出所を持たせることでした。

ts
type DetectedCommand = {
  value: string
  origin: 'repository' | 'catalog' | 'unknown'
}

これで「リポジトリが定義しているコマンド」と「カタログの既定値」を、UIでもプロンプトでも区別できるようになりました。AIレビューに渡す入力にも、[カタログ既定値・未検出] と明記されます。

ただ、これで直ったのはコマンドだけでした。言語もフレームワークもデプロイ先も、相変わらず結論だけを返していました。1回目と同じ場所に、同じ穴が空いたままだったわけです。

根拠を値と一体にする

リンドくんリンドくん

コマンドだけ直したんじゃ足りなかった、ということですか?

たなべたなべ

そう。言語もフレームワークもデプロイ先も、相変わらず結論だけを返していた。
1回目とまったく同じ穴が、別の場所に空いたままだったんだ。

次の修正は、すべての検出に根拠を持たせることでした。

ts
type DetectionEvidence =
  | { kind: 'file'; path: string }
  | { kind: 'manifest'; file: string; field: string }
  | { kind: 'dependency'; file: string; name: string }
  | { kind: 'metadata'; field: string }

type Detected<T> = { value: T | null; evidence: DetectionEvidence | null }

この形にすると、性質が変わります。

効き目はここ

ファイル名を挙げられない検出は、書けなくなります。 根拠のない既定値を返すには、根拠を捏造する必要があり、それはレビューで目に見える行為です。「うっかり書いてしまう」ことができなくなります。

規約や慣習ではなく、型がそれを引き受けます。これが「気をつける」との違いです。

真偽値は根拠から導く

同じ考え方は、構造的な判定にも効きます。「テストがある」のような真偽値は、先に一致するパスを探して、そこから真偽値を作ります。

ts
const path = findMatch(index, testPattern)
return { present: path !== null, path }

こうすると、「テストあり」と「根拠のパス」が食い違うことが、構造的に起こりえません。2つの値を別々に計算して、片方だけ更新してしまう事故が消えます。

弱い根拠は、弱いと名乗らせる

GitHub は各リポジトリの主要言語を返しますが、これはバイト数で決まります。巨大な CSS バンドルを同梱した TypeScript のプロジェクトが CSS と判定されることがあります。

だからこの根拠は、ファイルとは別の種類にしてあります。

ts
{ kind: 'metadata', field: 'language' }

画面には「根拠: GitHub のメタデータ(language)」と出ます。読む側が、その重みを自分で判断できます。

分からないなら、分からないと返す

リンドくんリンドくん

ただ、フォームの初期値としては「たぶん Vercel」でも埋まっていたほうが親切じゃないですか?

たなべたなべ

いい反論だと思う。実際、初期値としては役に立つよ。
問題はそれを事実の側に混ぜることなんだ。推測は推測のラベルを付けて渡せばいい。ラベルさえ付いていれば、生成される文書にもレビューにも「未検出」と書ける。

この設計の核心は、実は単純な一行に収まります。

null を返すことを、失敗ではなく正しい答えとして扱う。

デプロイ設定が無いリポジトリのデプロイ先は null です。それが真実です。「たぶん Vercel」は真実ではありません。フォームの初期値として推測が欲しい場面はありますが、そのときは推測であるとラベルを付けて渡せばよく、事実の側に混ぜてはいけません。

他のツールにも同じことが言えます

リンドくんリンドくん

これ、自分が作っているツールにも当てはまりそうだな……。

たなべたなべ

確かめ方はひとつだけ。その結論について「なぜそう判断したか」を、ツール自身が言えるか
言えないなら、それは検出じゃなくて推測だよ。

この失敗は FRKZ 固有のものではありません。プロジェクトを解析して結論を出すツールはすべて、同じ穴を持ちえます。

  • リポジトリからCIを生成するツール
  • 依存関係からセキュリティ上の指摘をするツール
  • コードベースを読んで設定を提案するエージェント

チェックの仕方はひとつです。その結論について、「なぜそう判断したのか」を、ツール自身が言えるかどうか。言えないなら、それは検出ではなく推測です。

FRKZ のリポジトリ解析は、すべての検出に根拠を併記するようになりました。公開リポジトリなら接続なしで試せます。