type FitMode = "crop" | "contain"が一発で出ても、自分ならその補完を成功とは数えない。
型だけ直って、previewのobject-fitがcoverのままなら画面は変わらない。presetとtestが古いままなら、次の人がその不整合を拾うことになる。一行の候補は正しくても、変更全体としては途中だ。
VS CodeチームはGitHub Copilotのinline suggestionsについて、completion、近距離のnext edit、長距離のeditを一つの3-in-1 modelで扱う設計を紹介している。ここで気になるのはmodel名よりも、ひとつの変更意図がどこまで切れずに運ばれるかだ。
そこで、React + TypeScriptの小さなInstagram previewをfixtureにする。比較するのは候補の見栄えではない。最初の編集からtypecheckとtestが通るまでの「編集経路」だ。
なお、この記事で使うlocal / nearby / cross-fileとEditPathRunは、評価用にこちらで決めるラベルとschemaである。GitHub CopilotやVS Codeの公式telemetryではない。
評価対象を3段階の距離に分ける
今回の変更意図は、これだけに固定する。
Instagram previewにcontain modeを追加する。
型、preset、preview、testの整合性を保つ。
編集経路は次のように分ける。
| level | 観測する場所 | 今回の例 |
|---|---|---|
local |
現在行とカーソル近傍 |
FitModeへcontainを追加 |
nearby |
同じfeature内の別関数 |
objectFitの計算と表示labelを更新 |
cross-file |
別fileの関連実装 | preset dataとtestを更新 |
距離が長ければ優秀、という話ではない。関係のないfileまで変えたら、むしろreview負荷は増える。見たいのは、必要な場所へ変更意図が届き、余計な場所へ広がっていないかだ。
同じ初期状態へ戻せるfixtureを作る
fixtureは小さくする。実プロダクト全体で試すと、補完の差よりrepositoryの偶然を測りやすい。
src/features/instagram/
presets.ts
types.ts
instagram-preview.tsx
instagram-preview.test.tsx
fixtures/instagram/
square.png
portrait.png
story.png
artifacts/inline-edit-path/
runs.jsonl
初期状態ではcropだけを持たせる。
// src/features/instagram/types.ts
export type FitMode = "crop"
export type InstagramPreset = {
id: "square" | "portrait" | "story"
width: number
height: number
fitMode: FitMode
}
preview側には変換関数を置く。
// src/features/instagram/instagram-preview.tsx
import type { CSSProperties } from "react"
import type { FitMode, InstagramPreset } from "./types"
export function toObjectFit(mode: FitMode): CSSProperties["objectFit"] {
return "cover"
}
export function InstagramPreview(props: {
src: string
preset: InstagramPreset
}) {
return (
<img
src={props.src}
alt=""
width={props.preset.width}
height={props.preset.height}
style={{ objectFit: toObjectFit(props.preset.fitMode) }}
/>
)
}
この時点ではFitModeも表示もcrop前提だ。開始位置はtypes.tsのFitModeに固定し、preview側を先回りして直しておかない。
比較用の作業場所も分けたい。普段のworktreeでreset --hardを繰り返すのは怖いので、自分なら使い捨てworktreeを作る。
git worktree add ../inline-edit-path-fixture \
-b experiment/inline-edit-path HEAD
cd ../inline-edit-path-fixture
# fixtureを追加して初期状態を保存する
git add src/features/instagram fixtures/instagram
git commit -m "fixture: baseline before contain mode"
BASELINE=$(git rev-parse HEAD)
runをやり直すときだけ、この使い捨てworktree内で初期commitへ戻す。
git reset --hard "$BASELINE"
git reset --hardは未commitの変更を消す。普段の作業directoryでは実行しない。
run metadataには、少なくとも次を残す。
- repositoryの初期commit
- editor version
- extension version
- 開始fileとcaret位置
- 固定した変更意図
modelの内部IDや非公開event名は推測して埋めない。
入力画像を先に固定する
画像の縦横比がrunごとに変わると、補完の評価に別の問題が混ざる。今回は次の3枚を固定する。
| fixture | size | ratio |
|---|---|---|
square.png |
1080 × 1080 | 1:1 |
portrait.png |
1080 × 1350 | 4:5 |
story.png |
1080 × 1920 | 9:16 |
同じ元画像からfixtureを用意するなら、ブラウザ内でpresetを選べるResize Image for Instagramで寸法を揃えておくと、画像準備の手順を固定しやすい。ここで測りたいのはresize作業ではないので、3枚を作ったらfixtureとしてcommitし、run中は触らない。
local: 現在行が直っても完了にしない
開始点はFitModeだ。
export type FitMode = "crop" | "contain"
候補を採用したら、まずlocal到達として記録する。ただし、この時点ではgateをpassにしない。現在行が正しいことと、変更全体が成立することは別だからだ。
partial acceptanceを使った場合も残す。候補の半分を人が直したのに、採用回数だけを見るとAIが全部書いたように見えてしまう。
nearby: 別関数へ意味が届いたかを見る
次は同じfeature内のtoObjectFit()を見る。
export function toObjectFit(mode: FitMode): CSSProperties["objectFit"] {
return mode === "contain" ? "contain" : "cover"
}
ここでは候補が出たかだけでなく、内容を読む。
-
containとcropを逆にしていないか - labelや分岐が古い値を参照していないか
- 無関係なrenameやformattingを広げていないか
- 候補が出なかったのか、表示した候補をrejectしたのか
最後の2つは分けておく。noneとrejectedを同じ失敗へ丸めると、次回の比較で何が変わったのか分からない。
cross-file: presetとtestまで追う
期待する変更pathを先に決めておく。
[
"src/features/instagram/types.ts",
"src/features/instagram/presets.ts",
"src/features/instagram/instagram-preview.tsx",
"src/features/instagram/instagram-preview.test.tsx"
]
presets.tsでは、たとえばStoryだけcontainへ切り替える。
// src/features/instagram/presets.ts
import type { InstagramPreset } from "./types"
export const presets: InstagramPreset[] = [
{ id: "square", width: 1080, height: 1080, fitMode: "crop" },
{ id: "portrait", width: 1080, height: 1350, fitMode: "crop" },
{ id: "story", width: 1080, height: 1920, fitMode: "contain" },
]
testは型が通ることではなく、previewの振る舞いを見る。
// src/features/instagram/instagram-preview.test.tsx
import { describe, expect, it } from "vitest"
import { toObjectFit } from "./instagram-preview"
describe("toObjectFit", () => {
it("contain modeでは画像全体をframe内へ収める", () => {
expect(toObjectFit("contain")).toBe("contain")
})
it("crop modeではframeを埋める", () => {
expect(toObjectFit("crop")).toBe("cover")
})
})
AIがtest fileへ移動しても、期待値を実装の誤りに合わせて書き換えたら意味がない。Storyは9:16、portraitは4:5というfixture側の前提と一緒にdiffを読む。
suggestion全文ではなく、最小の記録を残す
promptやproprietary codeを丸ごと収集する必要はない。比較に使う項目だけでよい。
type EditLevel = "local" | "nearby" | "cross-file"
type SuggestionState = "none" | "shown"
type Gate = "not-run" | "pass" | `fail:${string}`
type EditPathRun = {
runId: string
repositoryCommit: string
editorVersion: string
extensionVersion: string
level: EditLevel
targetPath: string
suggestion: SuggestionState
accepted: boolean
partialAcceptance: boolean
manualRepair: boolean
touchedPaths: string[]
gate: Gate
}
JSONLなら、途中でeditorを閉じてもそこまでの観測が残る。次はserialization形式の例であり、実測結果ではない。
{"runId":"<run-id>","repositoryCommit":"<sha>","editorVersion":"<version>","extensionVersion":"<version>","level":"local","targetPath":"src/features/instagram/types.ts","suggestion":"shown","accepted":false,"partialAcceptance":false,"manualRepair":false,"touchedPaths":[],"gate":"not-run"}
manualRepair: trueは、即failureという意味ではない。「AIの提案だけでは完了しなかった」という事実を残す欄だ。そこを隠さなければ、補完が役立ったrunと、人間が回収したrunをあとで分けられる。
Git差分とgateで完了を判定する
編集が終わったら、変更pathを取る。
git diff --name-only "$BASELINE"
期待外のpathが出たら、cross-file能力の加点にはしない。変更理由をreviewする。期待pathが足りない場合も、そのまま記録する。
次にtypecheckとbehavior testを走らせる。
pnpm tsc --noEmit
pnpm vitest run src/features/instagram
これはVite + Vitest構成の例なので、実際にはrepositoryのscriptへ合わせる。typecheckが通っても表示の意味は保証されない。testが通っても、期待値だけを都合よく変更していないかはdiffで確認する。
run途中ならnot-runのまま残す。空欄をpassに変えない。
失敗fixtureを先に決める
成功例だけでは、評価器そのものが甘くなる。最低限、次のfailureを区別できるようにする。
| fixture | 状態 | 記録したいこと |
|---|---|---|
local-only |
現在行だけ変更 |
cross-fileへ未到達 |
broad-but-wrong |
複数fileを変更 | aspect ratio誤りでtest fail |
green-typecheck-red-test |
型は通る | behavior testはfail |
manual-repair |
候補採用後に人が修正 | AIだけの完了には数えない |
broad-but-wrongでは、Storyの9:16とportraitの4:5を混同するcaseが使いやすい。複数fileへ進んだという見た目は派手だが、意味を壊していれば成功ではない。
一度のrunで4種類を全部再現する必要はない。それぞれ独立したfixtureにしたほうが、どの判定が壊れたか読みやすい。
結果表はgateを右端に置く
最初は空のtemplateでよい。
| run | local | nearby | cross-file | unexpected paths | manual repair | typecheck | test |
|---|---|---|---|---|---|---|---|
| 001 | accepted / rejected / none | accepted / rejected / none | accepted / rejected / none | none / paths | yes / no | pass / fail / not-run | pass / fail / not-run |
upgrade前後を比べる場合も、初期commit、editor設定、入力画像、開始位置、gateを固定する。数回のrunを製品benchmarkや統計的な精度と呼ぶのは無理がある。それでも、自分のrepositoryで「どこから手修正が増えたか」を見る回帰fixtureとしては使える。
acceptance rateだけでは、関連fileを人間が直した時間が消える。逆に、cross-fileの候補数だけでは不要な変更の広がりを見落とす。
見るべき場所は、最初のTabではなく、変更意図が切れた地点だ。localで止まったのか、別関数で意味を取り違えたのか、testまで届いたが期待値を壊したのか。そこまで記録すれば、補完modelを更新したときも「なんとなく良くなった」以外の話ができる。