🪤

AI コードレビュー 80件全 pass したのに、Godot 4 で parse error 13個出た話

に公開

TL;DR

  • Godot 4.4 の個人開発で、CodeRabbit + Codex MCP のAIコードレビューを 80 件以上回して全部 pass したコードを実機起動したら、GDScript の strict mode (warning_as_error) で parse error が 13 個出てきた
  • 静的レビューでは catch できない Variant 推論まわりの罠を 7 種類に整理
  • 実機起動 (Godot CLI または Godot MCP Pro) が AI レビューの代替ではなく 相補 という再認識

まず何が起きたか

個人開発で Godot 4.4 の 2D ARPG を作っている。Phase A.5 でエフェクトプリミティブ 80 種 + ポーション 30 種を実装し、PR を 8 本マージ。各 PR で CodeRabbit / Codex MCP のレビューを回し、合計 80+ 件のコメントを全部 pass させた。

それでも実機起動したらこれ。

SCRIPT ERROR: Parse Error: The variable type is being inferred from a Variant value, so it will be typed as Variant. (Warning treated as error.)
          at: GDScript::reload (res://scripts/effects/ProjectilePrimitives.gd:62)

13 個出てきた。

しかも godot --headless --check-only では parse OK と判定されるのに、通常起動 (--quit-after) では autoload 初期化で全部エラー化する。--check-only と warning_as_error の適用範囲が違うのが地味に厄介。

修正に丸 1 セッション使ったので、同じ罠を踏まないように 7 種類にまとめる。


罠 1: const PackedStringArray は constant expression にならない

autoload の起動が通らず、依存する全 Script が compile error の連鎖を起こすパターン。

# ❌ コンストラクタ呼び出しは constant expression と認識されない
const WAVE2_PENDING := PackedStringArray([
    "potion_id_1", "potion_id_2",
])

# ✅ Array[String] なら literal として constant expression
const WAVE2_PENDING: Array[String] = [
    "potion_id_1", "potion_id_2",
]

PackedStringArray([...]) は実行時に呼ばれるコンストラクタ。const は compile-time 確定の値しか受け付けないので不適。Array[String] = [...] は literal なので OK。


罠 2: var x := variant_func() の Variant 推論

戻り値型が Variant (例: nullable な値を null チェックで返す関数) を := で受けると、left-hand side が Variant 推論されて warning_as_error。

static func _parse_direction(args: Dictionary) -> Variant:
    var raw = args.get("direction", null)
    if not (raw is Vector2):
        return null
    return (raw as Vector2).normalized()

# ❌ Variant 推論で warning_as_error
var direction := _parse_direction(args)
if direction == null:
    return {"ok": false}

# ✅ Variant で受けて、null チェック後に as Vector2 cast
var dir_v: Variant = _parse_direction(args)
if dir_v == null:
    return {"ok": false}
var direction: Vector2 = dir_v as Vector2

GDScript 4 strict mode は var x := ... で右辺が Variant の場合「型が決まらない」という warning を出す。これが warning_as_error 設定でエラー化。


罠 3: weakref() の戻り値が Variant 推論

weakref() の戻り値はシグネチャ上 Variant。実体は WeakRef オブジェクトだが、変数に受けるときに Variant 扱い。

# ❌ Variant 推論で warning_as_error
var weak_caster := weakref(caster)

# ✅ 明示型注釈 + as cast
var weak_caster: WeakRef = weakref(caster) as WeakRef

Object→WeakRef 変換は実装上は確定なんだけど、static type 分析的には Variant に落ちる。


罠 4: ternary f(x) if cond else null も Variant 推論

三項演算子の片側が null だと、もう片側が具体型でも Variant 推論されてしまう。

# ❌ Variant 推論
var c = weak_caster.get_ref() if weak_caster != null else null

# ✅ if/else 文に展開 + 型注釈
var c: Node = null
if weak_caster != null:
    var ref = weak_caster.get_ref()
    if ref != null and is_instance_valid(ref):
        c = ref

GDScript 4 の三項演算子は両辺の型を合一しようとして失敗 (Object と null) → Variant にフォールバック。1 行で書きたい誘惑があるが、Strict mode では if/else 文が安全。


罠 5: clamp() は引数依存で Variant 戻り

clamp(x, lo, hi) は int / float / Vector2 を受けられる generic 関数のため、戻り値型が Variant。

# ❌ Variant 推論
var amp := clamp(amplitude, 0.0, 100.0)
var dur := clamp(duration, 0.01, 1.0)

# ✅ float 専用 clampf を使う + 型注釈
var amp: float = clampf(amplitude, 0.0, 100.0)
var dur: float = clampf(duration, 0.01, 1.0)

Strict mode を有効にしているなら、clampf() (float) / clampi() (int) のように 専用版 を使うのが鉄則。min / max も同じ理屈で minf / maxf / mini / maxi を使う。


罠 6: lambda の値型キャプチャで外側変数が更新されない

これは parse error にはならない。だが機能バグになる。今回いちばん刺さったやつ。

GDScript 4 の lambda は外側スコープの変数を 値型 でキャプチャする (Lua / Python と同じ)。なので外側 var+= / -= / *= しても元の変数には反映されない。

# ❌ 値型キャプチャ → 外側の ticks_left は永遠に減らない
var ticks_left: int = 5
timer.timeout.connect(func():
    ticks_left -= 1   # 外側に反映されない (DOT 無限ループ)
    if ticks_left <= 0:
        timer.queue_free()
)

# ✅ Dictionary は参照型 → state 経由で更新
var state := {"ticks_left": 5}
timer.timeout.connect(func():
    var n: int = (state["ticks_left"] as int) - 1
    state["ticks_left"] = n
    if n <= 0:
        timer.queue_free()
)

警告メッセージは CONFUSABLE_CAPTURE_REASSIGNMENT: Reassigning lambda capture does not modify the outer local variable。これが出たら DOT timer / 追尾弾の方向更新 / 貫通弾の damage decay みたいな「Timer 内累積処理」がほぼ確実に機能していない。

Dictionary を経由すれば、Dictionary 自体は参照型なので「中身を書き換える」操作が外側に反映される。


罠 7: ternary で型注釈忘れの量産バグ

罠 4 の派生だが、別パターンも頻発する。

# ❌ ternary で型不明 (caster が null なら -1、そうでないなら instance_id)
var caster_id: int = caster.get_instance_id() if (caster != null) else -1

# ✅ if/else に展開
var caster_id: int = -1
if caster != null and is_instance_valid(caster):
    caster_id = caster.get_instance_id()

書きやすいんだけど、Strict mode だと両辺型の合一が失敗するパターンが多い。1 行で書く誘惑に勝つ。


なぜ AI レビューが catch できなかったか

CodeRabbit も Codex も、コードのロジック・セキュリティ・設計パターンには強い。これは間違いない。むしろ A.5-5 / A.5-7 で 80+ 件レビューを受けて、設計レベルの欠陥はかなり潰せた。

ただ以下は苦手だった。

  • 言語処理系のバージョン依存挙動 — Godot 4.4 の strict mode は静的解析ツールに反映されにくい
  • --check-only と通常 load の差 — parser の警告レベル設定が文脈で変わる
  • lambda capture の意味論 — 値型 / 参照型の違いを「機能バグ」として認識する難易度

var x := variant_func() も、ロジック的には正しい。「型推論が Variant になる」のは strict mode 設定 + warning_as_error の組み合わせで初めて表面化するエラーで、AI が手元にコードだけ与えられても判断しづらい。

結論として、実機検証は AI レビューの「代替」ではなく「相補」。両方やる必要がある。


実機検証の最短フロー (Godot 4.4)

CLI で parse 確認

# autoload 初期化まで含めて確認
godot --headless --quit-after 30 --path . 2>&1 | grep -E "Parse Error|Compile Error"

--check-only だけでは catch できない warning_as_error も、通常起動 (--quit-after) なら autoload で全部出る。WSL なら native godot バイナリを apt install godot4 等で入れておくと CI 用にも便利。

Godot MCP Pro で TestRunner 起動

Claude Code から MCP 経由でテスト実行できる。

mcp__godot-mcp-pro__play_scene(mode="main")
mcp__godot-mcp-pro__execute_game_script(code='''
    var result := EffectRegistryTestRunner.run_all()
    _mcp_print("pass: " + str(result.pass) + " / fail: " + str(result.fail))
''')

今回の修正後、EffectRegistryTestRunner で 102 PASS / 0 FAIL、PotionRegistryTestRunner で 181 PASS / 0 FAIL、合計 283 PASS / 0 FAIL を確認できた。

Script Editor の auto-save 巻き戻し対策

おまけだが、Godot エディタを開いたままファイルを外部 (例: Claude Code の Edit tool / VSCode) で書き換えると、Script Editor の memory cache が auto-save で 古い内容を書き戻す 罠がある。今回これに 30 分以上ハマった。

対策:

  • 修正中は Script Editor で対象ファイルを開かない
  • または検証は Godot CLI (エディタプロセスを介さない) で
  • どうしても両方使うなら、エディタ再起動で memory cache をリセット

まとめ

キーワード 対応
1 const PackedStringArray Array[String] に書き換え
2 var := variant_func() Variant で受けて as cast
3 weakref() 戻り WeakRef 型注釈 + as WeakRef
4 ... if ... else null if/else 文に展開
5 clamp() clampf() / clampi()
6 lambda 値型 capture Dictionary 経由で参照型化
7 ternary 全般 型注釈 + if/else 文

AI レビュー 80+ 件全 pass しても、GDScript 4 strict mode の罠は別軸で残る。実機起動を「最後の砦」として workflow に組み込むのが現実解だった。

primitive 系の機能を AI 任せで量産している人は、CONFUSABLE_CAPTURE_REASSIGNMENT の警告を見たら一度全 lambda を点検する価値あり。DOT がずっと続く / homing が真っ直ぐ飛ぶ / pierce が減衰しないみたいな「気づきにくい機能バグ」がそこに潜んでる可能性が高い。


検証環境: Godot 4.4-stable / WSL2 Ubuntu / CodeRabbit Pro / Codex MCP

GitHubで編集を提案

Discussion