連載|静的解析×AIエージェントを始めよう! 第3回 GitHub Copilot×SARIFの留意点(中編)

前回は、SARIF Viewerで表示した静的解析の指摘に対して、VS Code上でGitHub Copilot Chatを使って説明・修正する際に、GitHub Copilotは果たして処理の流れを理解した上で動作しているのかを確かめる検証に着手しました。

今回は、さらに精査を進めていきます。

Artist's impression of a multi-core CPU

この連載の記事一覧

連載|静的解析×AIエージェントを始めよう!

ステップ3:その「箱」を、PROBLEMSパネルとCopilotはそれぞれどう読んでいるのか

ステップ1で作られたResultDiagnosticのインスタンスは、この後2つの経路で読まれます。1つはVS Code本体によるPROBLEMSパネルへの描画、もう1つはGitHub Copilot Chatによる推論です。実際にVS Code本体のソースコードを見ながら、この2つの経路を確認します。

3-1.PROBLEMSパネルが読んでいるもの

DiagnosticCollection.set()が内部で2つのことを同時に行っています。1つは、渡されたDiagnosticを、拡張機能ホスト(Extension Host)側にそのまま保持しておくこと。もう1つは、その内容を変換してMain Thread側へ送ることです。前者が後にGitHub Copilot Chatが読むもの(ステップ3-2)、後者がPROBLEMSパネルへ描画されるものになります。

// src/vs/workbench/api/common/extHostDiagnostics.ts (microsoft/vscode, main)
set(first: vscode.Uri | ReadonlyArray<[vscode.Uri, ReadonlyArray<vscode.Diagnostic>]>, diagnostics?: ReadonlyArray<vscode.Diagnostic>) {
    // ...
    // 渡されたDiagnosticを、拡張機能ホスト側にそのまま保持する
    this.#data.set(first, coalesce(diagnostics));
    toSync = [first]; // Main Threadへの送信対象として記録する
    // ...
}

https://github.com/microsoft/vscode/.../extHostDiagnostics.ts

保持先の#dataは、DiagnosticCollection自身が内部に持っている入れ物で、ファイルのURIをキーにDiagnosticの配列を持ちます。ここに入るのは、変換されていないインスタンスそのものです。

PROBLEMSパネルの描画を実際に担当しているのは、拡張機能ホストとは別のMain Thread(レンダラープロセス)です。そのMain Thread側へ実際に送信しているのが、同じファイルの次の箇所です。

Main Threadとは

拡張機能(ここでいうSARIF Viewer)が動くExtension Host側ではなく、VS Code本体のWorkbench/UI側を指します。VS Codeの拡張機能API内部実装では、このUI/Workbench側がMainThread*という名前で表現されています。

// src/vs/workbench/api/common/extHostDiagnostics.ts (microsoft/vscode, main)
marker = diagnostics.map(diag => ({ ...converter.Diagnostic.from(diag), modelVersionId: this._modelVersionIdProvider(uri) }));
// ...
entries.push([uri, marker]);
// ...
this.#proxy.$changeMany(this._owner, entries);

送信先である$changeManyのインターフェース宣言を見ると、渡せるものが型レベルで確定していることが分かります。

// src/vs/workbench/api/common/extHostDiagnostics.ts (microsoft/vscode, main)
$changeMany(owner: string, entries: [UriComponents, IMarkerData[] | undefined][]): void

つまり、拡張機能ホストからMain Thread側へ渡るのは、converter.Diagnostic.from(diagnostic)で変換されたIMarkerDataだけです。この変換関数の実体は次の通りです。

// src/vs/workbench/api/common/extHostTypeConverters.ts (microsoft/vscode, main)
export namespace Diagnostic {
    export function from(value: vscode.Diagnostic): IMarkerData {
        let code: string | { value: string; target: URI } | undefined;

        if (value.code) {
            if (isString(value.code) || isNumber(value.code)) {
                code = String(value.code);
            } else {
                code = {
                    value: String(value.code.value),
                    target: value.code.target,
                };
            }
        }

        return {
            ...Range.from(value.range),
            message: value.message,
            source: value.source,
            code,
            severity: DiagnosticSeverity.from(value.severity),
            relatedInformation: value.relatedInformation && value.relatedInformation.map(DiagnosticRelatedInformation.from),
            tags: Array.isArray(value.tags) ? coalesce(value.tags.map(DiagnosticTag.from)) : undefined,
        };
    }
    // ...
}

https://github.com/microsoft/vscode/.../extHostTypeConverters.ts

見ての通り、返り値として組み立てられているIMarkerDataのフィールドは、range(Range.fromで展開)・message・source・code・severity・relatedInformation・tagsだけです。これはステップ1で見たDiagnosticクラスが持つ7つの標準フィールドとちょうど一致しており、value(引数に渡されたResultDiagnosticインスタンス)が独自に持っている.resultプロパティを読みに行くコードはどこにもありません。つまり、PROBLEMSパネル側の絞り込みは、この変換関数の戻り値の型がIMarkerDataであり、そこに.resultを入れる余地がないことによるものです。

実際に、PROBLEMSパネル上の項目を右クリックして「コピー」を実行すると、次のような情報が取得できます。

[{
	"resource": "/null_ptr.c",
	"owner": "SARIF",
	"severity": 8,
	"message": "Null pointer dereference: ptr",
	"startLineNumber": 5,
	"startColumn": 16,
	"endLineNumber": 5,
	"endColumn": 16,
	"modelVersionId": 1,
	"origin": "<省略>"
}]

(resourceとoriginの値は一部を伏せています。)

この内容を見ると、resource・severity・message・startLineNumberなど、VS Code標準のDiagnostic/Markerに対応する情報が中心で、SARIF Viewer独自の.resultプロパティは含まれていません。あわせて、codeフィールドが存在しないことも見て取れます。codeは、ESLintのルール名やTypeScriptのエラー番号のように、指摘の識別子を入れるための標準フィールドです。SARIF ViewerはここにruleIdを入れていないため、nullPointerというルールIDはこの経路には現れません。なお"owner": "SARIF"は、ステップ1で見たlanguages.createDiagnosticCollection('SARIF')のコレクション名に由来し、modelVersionIdは先ほどの送信処理でマーカーへ変換する際に付与されたものです。

ここで重要なのは、IMarkerData自体にはrelatedInformationを運ぶ仕組みがある、という点です。つまり、PROBLEMSパネルへ複数の関連位置を渡せないわけではありません。今回CodeFlowに関する情報が出てこないのは、その手前でSARIF ViewerがResultDiagnosticを作る際にrelatedInformationを設定していないためです。IMarkerDataへの変換は、渡されたDiagnosticの標準フィールドをMarker情報へ移すだけなので、SARIF Viewerが載せなかった情報を後段のPROBLEMSパネルが復元することはできません。

3-2.GitHub Copilot Chat(Fix/Explain)が読んでいるもの

次に、GitHub Copilot Chat側で、PROBLEMSパネル上のエラーやワーニング、エディタ上の指摘を右クリックしたときに表示されるFix・Explainが、実際に何を受け取っているかを見ていきます。

これらのメニュー項目の一部は、VS CodeのCodeActionProviderという仕組みで提供されています。Copilot側には、Fix・Explainに関するCode Actionを返すQuickFixesProviderが実装されています。なお、現在のVS Code/Copilotではインラインチャットの表示設定や入口によって、後述するinlineChat.fixDiagnosticsのような別経路が使われる場合もあります。そのため、ここではまずQuickFixesProvider経由の動きを確認し、その後ステップ5で入口ごとの差を整理します。画像のalt text生成や「Review」アクションなど機能が増え、各種サービスをDI(依存性注入)で受け取る構造になっていますが、Fix・Explainに関わる本質的な部分は次の通りです。

// extensions/copilot/src/extension/inlineChat/vscode-node/inlineChatCodeActions.ts (microsoft/vscode, main)
export class QuickFixesProvider implements vscode.CodeActionProvider {

	static getWarningOrErrorDiagnostics(diagnostics: ReadonlyArray<vscode.Diagnostic>): vscode.Diagnostic[] {
		return diagnostics.filter(d => d.severity <= vscode.DiagnosticSeverity.Warning);
	}

	static getDiagnosticsAsText(diagnostics: ReadonlyArray<vscode.Diagnostic>): string {
		return diagnostics.map(d => d.message).join(', ');
	}

	async provideCodeActions(doc, range, context, cancellationToken) {
		// ...
		const severeDiagnostics = QuickFixesProvider.getWarningOrErrorDiagnostics(context.diagnostics);
		// ...
		const diagnostics = QuickFixesProvider.getDiagnosticsAsText(severeDiagnostics);

		const fixAction = new AICodeAction(vscode.l10n.t('Fix'), QuickFixesProvider.fixKind);
		fixAction.diagnostics = severeDiagnostics;
		fixAction.command = {
			title: fixAction.title,
			command: 'vscode.editorChat.start',
			arguments: [{ autoSend: true, message: `/fix ${diagnostics}`, /* ... */ }],
		};

		const explainAction = new AICodeAction(vscode.l10n.t('Explain'), QuickFixesProvider.explainKind);
		explainAction.diagnostics = severeDiagnostics;
		const query = `/${Intent.Explain} ${diagnostics}`;
		explainAction.command = {
			title: explainAction.title,
			command: 'github.copilot.chat.explain',
			arguments: [query],
		};
		// ...
	}
}

https://github.com/microsoft/vscode/.../inlineChatCodeActions.ts

ポイントはgetDiagnosticsAsTextにあります。context.diagnosticsやvscode.languages.getDiagnostics()から取得される診断は、ステップ3-1で見た拡張機能ホスト側にそのまま保持されているDiagnostic、つまりSARIF Viewerが作ったResultDiagnosticそのものです。そのためJavaScriptオブジェクト上には独自プロパティ.resultも残っています。

まず、vscode.languages.getDiagnostics(uri)の実体は次の通りです。

// src/vs/workbench/api/common/extHostDiagnostics.ts (microsoft/vscode, main)
private _getDiagnostics(resource: vscode.Uri): ReadonlyArray<vscode.Diagnostic> {
	let res: vscode.Diagnostic[] = [];
	for (const collection of this._collections.values()) {
		if (collection.has(resource)) {
			res = res.concat(collection.get(resource));
		}
	}
	return res;
}

https://github.com/microsoft/vscode/.../extHostDiagnostics.ts

this._collectionsは、createDiagnosticCollection()で作られたコレクションの集合です。つまりステップ3-1で保持されたインスタンスを、そのまま連結して返しているだけです。

一方のcontext.diagnosticsは、CodeActionの呼び出しを受け取る側で組み立てられています。

// src/vs/workbench/api/common/extHostLanguageFeatures.ts (microsoft/vscode, main)
const allDiagnostics: vscode.Diagnostic[] = [];

for (const diagnostic of this._diagnostics.getDiagnostics(resource)) {
	if (ran.intersection(diagnostic.range)) {
		allDiagnostics.push(diagnostic);
		// ... 1ファイルあたりの件数上限に達したら打ち切る
	}
}

const codeActionContext: vscode.CodeActionContext = {
	diagnostics: allDiagnostics,
	// ...
};

const commandsOrActions = await this._provider.provideCodeActions(doc, ran, codeActionContext, token);

https://github.com/microsoft/vscode/.../extHostLanguageFeatures.ts

こちらが参照しているのも同じthis._diagnostics.getDiagnostics(resource)で、選択範囲と交差するものだけを絞り込んでcodeActionContextに載せ、拡張機能のprovideCodeActionsへ渡しています。つまりどちらの入口でも、Copilot側に渡っているのはSARIF Viewerが作ったインスタンスそのものです。

つまり、Copilot Chat側のコードが受け取っているのは、PROBLEMSパネルのIMarkerDataのような「表示用に絞られたコピー」ではなく、.resultプロパティも含んだ、SARIF Viewerが作ったResultDiagnosticインスタンスの参照そのものです。理論上は、as ResultDiagnosticというキャストを書けば、.resultまで読みに行くこともできます(実際にSARIF Viewer自身は、自分のUI表示のためにこれをやっています)。

しかし、getDiagnosticsAsTextが実際にやっているのは次のことだけです。

static getDiagnosticsAsText(diagnostics: ReadonlyArray<vscode.Diagnostic>): string {
	return diagnostics.map(d => d.message).join(', ');
}

見ての通り、getDiagnosticsAsTextが取り出しているのはd.messageだけです。Cppcheckのnull_ptr.cの例なら、診断メッセージはNull pointer dereference: ptrです。

Copilotには、このほかにDiagnostic.range周辺のコードやrelatedInformationを使う仕組みもあります。ただしそれが働くかどうかは経路によって異なり、右クリックのFixがたどる経路ではrelatedInformationは読まれません(ステップ5で追います)。いずれにしても今回のCppcheckの例では、ResultDiagnostic.rangeには先頭の位置しか入らず、relatedInformationは空です。SARIF固有のcodeFlowsや.resultを読み取ってプロンプトへ追加する処理もありません。

つまり、元のSARIFに含まれていた複数の位置情報やCodeFlowの大部分は、Fix/Explainの推論材料には入っていません。

なお、コード中にはfixAction.diagnostics = severeDiagnostics・explainAction.diagnostics = severeDiagnosticsという代入もあります。ただし、ここで設定しているdiagnosticsはvscode.CodeActionが持つUI用のメタデータです。getDiagnosticsAsTextの戻り値として/fix・/explainに組み込まれる文字列とは別物です。

※ ここで組み立てられた/fix ...という文字列が、そのままモデルへ届くとは限りません。この点はステップ5で追います。

まとめ:同じ「箱」を、2つの経路がそれぞれ違う理由で狭めている

ここまでを整理すると、次のようになります。SARIF ViewerがResultDiagnosticに持たせている.resultという独自プロパティと、Diagnosticが標準で持つrelatedInformationというフィールドとで、実は事情が異なるので分けて整理します。

経路.result (独自プロパティ)relatedInformation (標準フィールド)
PROBLEMS パネル
(UI)
渡らない:IMarkerDataへの変換(converter.Diagnostic.from)が読みに行くのは、宣言済みのvscode.Diagnostic標準フィールドだけで、.resultは最初から対象外(構造的な制約)渡る器はある:converter.Diagnostic.fromはrelatedInformationもきちんと変換対象にしている。ただしステップ2で見た通りSARIF Viewer自身がここに何も詰めていないため、実際には空
GitHub Copilot Chat
(Fix/Explain)
.resultも残っているが、Copilot側はSARIF Viewer固有の型へ依存せず読みに行かない経路によって異なる:入口によって読む場合と読まない場合がある(ステップ5で詳述)。ただしステップ2の絞り込みにより、SARIF Viewer由来では空

.resultについては、PROBLEMSパネル側では標準のMarker表現へ変換されるため対象外です。一方Copilot側では同じResultDiagnosticを参照できますが、標準のvscode.Diagnosticとして扱うため独自プロパティを利用しません。

relatedInformationについても補足します。「Copilot Chat」とひとくくりにしましたが、実際には通常のインラインFix系入口と、Copilot Chatパネルへの/fix直接入力とで、たどるコードパスが異なります。後者だけがrelatedInformationを読むDiagnosticRelatedInfoに到達しますが、この違いはステップ5で詳しく追います。

しかし、codeFlowsが実際に両経路で失われている一番手前の原因は、この.resultをめぐる話ではありません。relatedInformationという標準フィールドは、PROBLEMSパネルへも運べますし、Copilotにもそれを読む仕組み(DiagnosticRelatedInfo)があります。つまりどちらの経路も、relatedInformationが渡ってくればそれを使う準備はできています。それでもcodeFlowsがどちらにも載らないのは、そもそもStep2の時点でSARIF Viewer自身がrelatedInformationに何も詰めていない、という両経路に共通する、より上流の絞り込みが先に効いているからです。

つまり、Cppcheckのように単純なlocations[]だけの出力であっても、CodeSonarのようにcodeFlowsを埋め込んだ出力であっても、結論は変わりません。次のステップ4以降では、Explain・Fixそれぞれの実装をさらに掘り下げながら、この結論を補強していきます。

ステップ4:Explainの中身をさらに追う

Explainが診断を扱う部分は、ステップ3-2で見たCodeActionと同じ作りです。QuickFixesProviderがcontext.diagnosticsからgetDiagnosticsAsText—つまりd.messageだけ—を取り出し、/explain <診断メッセージ>という文字列を組み立てて、github.copilot.chat.explainコマンドへ引数として渡します。

そのコマンドの実体であるdoExplainは、文字列が渡されていればそれをそのまま使い、最後にCopilot Chatパネルを開くコマンドを呼びます。

// extensions/copilot/src/extension/inlineChat/vscode-node/inlineChatCommands.ts (microsoft/vscode, main)
const doExplain = async (arg0: any, fromPalette?: true) => {
	let message = `/${Intent.Explain} `;
	// ...
	if (typeof arg0 === 'string' && arg0) {
		message = arg0;
	}
	// ...
	vscode.commands.executeCommand('workbench.action.chat.open', { query: message });
};

https://github.com/microsoft/vscode/.../inlineChatCommands.ts

workbench.action.chat.openは、Copilot Chatパネルを開いてクエリを渡すコマンドです。次のステップ5で見るFixとは違ってvscode.editorChat.startを経由しないため、VS Codeコア側でメッセージが差し替えられることはなく、組み立てた文字列がそのままExplainの入力になります。

いずれにせよ、Explainが診断について渡しているのは診断メッセージが中心です。Copilot Chat側が通常の文脈として開いているファイルなどを別途プロンプトに含めることはありますが、SARIFのcodeFlowsや.resultを診断情報として読み出す処理は確認できませんでした。

ステップ5:Fixの中身をさらに追う—実は「抜け道」はあるが、届くのは限られた経路とコードだけ

これまでに、Fixは右クリックからの実行と言及していましたが、実はVS Code上でFixを呼び出す方法は、1つではありません。よく使うのは次のあたりです。

呼び出し方実体本稿での扱い
PROBLEMSパネルで指摘を右クリック、またはQuick Fix(Ctrl+.)の一覧から「Fix」Copilotが提供するCodeAction(QuickFixesProvider)5-1・5-2
エディタ上で波線を含む範囲を選択して右クリックし「Fix」コマンドgithub.copilot.chat.fix5-1・5-2
エラーや警告の波線にマウスを乗せ、ホバーに出る「Fix」コマンドinlineChat.fixDiagnostics5-1・5-2
Copilot Chatパネルの入力欄に/fix ...と直接入力5-3

呼び出し方はいろいろですが、最後の1つを除いてすべてvscode.editorChat.start—VS Code本体のインラインチャット—に合流します。そしてこの合流地点で、/fixという文字列は定型文へ差し替えられ、Copilotの意図分類までは届きません。唯一、パネルへの直接入力だけがFixIntentにたどり着きます。

まず、日常的によく使う前者(5-1・5-2)から追い、そのあとで後者(5-3)を見ます。

5-1.実際に右クリックのFixを動かすと、この/fix文字列はVS Codeコア側で差し替えられる

ここまではQuickFixesProvider.provideCodeActionsが組み立てる、vscode.editorChat.startへの呼び出し引数({ autoSend: true, message: '/fix ${diagnostics}' })を追いました。ところが、実際にCopilot Chatのリクエストログ(コマンドパレットからShow Chat Debug Viewerを実行して表示されるデバッグパネルの.copilotmd)を採取して右クリックのFixを動かしてみると、モデルに渡っているメッセージは、この/fix ${diagnostics}ではなく"Fix the attached problem"(複数件なら"Fix the attached problems")という定型文でした。Explainでは起きないこの食い違いを追ったところ、原因はCopilot拡張機能の外側、VS Code本体(コア)のInlineChatControllerにありました。

ここで重要なのは、/fix ...という文字列が作られているからといって、それがCopilot Chatパネルへ直接入力されたslash commandと同じ扱いになるわけではない、という点です。違いは、/fix ...を「どの関数・コマンドの引数として渡しているか」にあります。

通常のCodeAction経由(右クリックから実行)のFixでは、流れは次のようになります。

QuickFixesProvider.provideCodeActions()
  -> fixAction.command
     command: "vscode.editorChat.start"
     arguments: [{
       autoSend: true,
       message: "/fix " + diagnostics,
       initialRange: ...
     }]

VS Code core
  -> InlineChatController.run(arg)
  -> InlineChatController.#runZone(session, arg)
     -> arg.attachDiagnostics ??= true
     -> getLiveMarkers(uri) で現在のMarkerを再取得
     -> chatWidget.attachmentModel.addContext(...)
     -> arg.message = "Fix the attached problem(s)"
     -> chatWidget.acceptInput()

つまり、/fix ...はあくまでvscode.editorChat.startコマンドのmessage引数です。Copilot Chatパネルの入力欄にユーザーが直接/fix ...と入力する場合とは、最初に通る関数が違います。前者はまずVS Codeコア側のインラインチャット処理に入り、そこで診断の添付とメッセージの差し替えが行われます。後者はvscode.editorChat.startを経由しないため、この差し替えを受けずにCopilot側のFixIntentへ進みます。

// src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts (microsoft/vscode, main)
async #runZone(session: IInlineChatSession, arg?: InlineChatRunOptions): Promise<boolean> {
	// ...
	if (arg) {
		arg.attachDiagnostics ??= true;
	}

	// ADD diagnostics (only when explicitly requested)
	if (arg?.attachDiagnostics) {
		const entries: IChatRequestVariableEntry[] = [];
		for (const [range, marker] of this.#markerDecorationsService.getLiveMarkers(uri)) {
			if (range.intersectRanges(this.#editor.getSelection())) {
				const filter = IDiagnosticVariableEntryFilterData.fromMarker(marker);
				entries.push(IDiagnosticVariableEntryFilterData.toEntry(filter));
			}
		}
		if (entries.length > 0) {
			this.#zone.value.widget.chatWidget.attachmentModel.addContext(...entries);
			const msg = entries.length > 1
				? localize('fixN', "Fix the attached problems")
				: localize('fix1', "Fix the attached problem");
			this.#zone.value.widget.chatWidget.input.setValue(msg, true);
			arg.message = msg;
			// ...
		}
	}
	// ...
	if (arg.message) {
		this.#zone.value.widget.chatWidget.setInput(arg.message);
		if (arg.autoSend) {
			await this.#zone.value.widget.chatWidget.acceptInput();
		}
	}
	// ...
}

https://github.com/microsoft/vscode/.../inlineChatController.ts

vscode.editorChat.startは拡張機能から見ればただのコマンドですが、実体はVS Code本体のこのInlineChatController.run(内部で#runZoneを呼びます)です。ここに渡されたarg(QuickFixesProviderが組み立てた{ autoSend: true, message: '/fix ...' })は、attachDiagnosticsを明示的に指定していない限りtrueにデフォルトされます(arg.attachDiagnostics ??= true)。そしてattachDiagnosticsがtrueの場合、コントローラは選択範囲と交差する現在アクティブなマーカー(診断)を自前で取得し直し、それらを添付ファイル(チャット変数)としてウィジェットに追加したうえで、arg.messageそのものを"Fix the attached problem(s)"という定型文で上書きします。なお引用したコードの通り、この上書きが行われるのは交差するマーカーが1件以上あった場合(if (entries.length > 0))に限られます。診断を右クリックして実行する通常の使い方ではこの条件を満たすため実質的に必ず差し替わりますが、交差するマーカーが1件も無ければ/fix ...という文字列はそのまま送られます。

つまり、QuickFixesProviderがステップ3-2で見たgetDiagnosticsAsText(d.messageだけを結合したもの)を使って一応組み立てていた/fix ${diagnostics}という文字列は、右クリックの通常のFixフローでは、Copilotの意図分類(Intent Classification)に届く前に、VS Codeコア側で丸ごと破棄・差し替えられています。Copilot拡張機能が実際に受け取るのは、この定型文と、コアがMarkerを手掛かりに選び直した診断だけです。

ここで補足しておくと、コアが取得し直しているMarkerが、そのままCopilotへ渡るわけではありません。MarkerはIDiagnosticVariableEntryFilterData​.fromMarker()によって、どの診断を指すのかを表す条件へ変換されます。

// src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts (microsoft/vscode, main)
export function fromMarker(marker: IMarker): IDiagnosticVariableEntryFilterData {
	return {
		filterUri: marker.resource,
		owner: marker.owner,
		problemMessage: marker.message,
		filterRange: { startLineNumber: marker.startLineNumber, endLineNumber: marker.endLineNumber, startColumn: marker.startColumn, endColumn: marker.endColumn }
	};
}

https://github.com/microsoft/vscode/.../attachments/chatVariableEntries.ts

作られているのは、ファイル・範囲・owner・メッセージという「どの診断か」を指すための情報です。そしてこの条件で拡張機能ホスト側の診断—つまりステップ3-1で保持されたResultDiagnostic—を選び直したものが、添付ファイルとしてCopilotへ渡ります。したがってこの経路でも、Copilotの手元に届くのは.resultを持つインスタンスのままです。差し替えられるのは入力文と、どの診断を添付するかという選び方だけです。

5-2.差し替えられた後にCopilotが受け取るもの—DiagnosticVariable

コアが添付した診断は、Copilot拡張機能側ではDiagnosticVariableというプロンプト要素が処理します。これはFix専用の仕組みではなく、診断が「添付ファイル」としてチャットへ渡されたときに汎用的に呼ばれる仕組みです(右クリックのFixだけでなく、インラインチャットやパネルのチャットで診断を添付した場合にも使われます)。

// extensions/copilot/src/extension/prompts/node/panel/chatVariables.tsx (microsoft/vscode, main)
interface IDiagnosticVariableProps extends BasePromptElementProps {
	diagnostics: [uri: Uri, diagnostics: Diagnostic[]][];
	useCookbook?: boolean;
	// useRelatedInfo?: boolean;
}

class DiagnosticVariable extends PromptElement<IDiagnosticVariableProps> {
	render() {
		return <>
			{this.props.diagnostics.flatMap(([uri, diagnostics]) =>
				diagnostics.map(d => {
					// ...
					return <>
						<Tag name='error' attrs={{ path: /* ... */, line: d.range.start.line + 1, code: getDiagnosticCode(d), severity: /* ... */ }}>
							{d.message}
						</Tag>
						{cookbook && <DiagnosticSuggestedFix cookbook={cookbook} />}
					</>;
				})
			)}
		</>;
	}
}

https://github.com/microsoft/vscode/.../chatVariables.tsx

DiagnosticVariableがプロンプトに書き出しているのは、タグの属性としてのファイルパス・行番号・code・severity(重大度)と、その中身としてのd.message(診断メッセージそのもの)、そしてuseCookbookが有効な場合のCookbookによる修正提案だけです。Cookbookとは、Copilot拡張機能が内蔵している、特定のツールの特定のルール(ESLintの個々のルールや、TypeScriptのエラー番号など)に対してあらかじめ用意された修正の指示文のことです。診断のsourceとcodeを手掛かりに引き当てます。なお属性のcodeは、getDiagnosticCode(d)がDiagnostic.codeをString()で文字列化したものです。ステップ3-1で確認した通りSARIF Viewerはcodeを設定していないため、nullPointerというルールIDがこの属性に現れることはありません(未設定のまま文字列化されるので、実装上は"undefined"という文字列になります)。5-3で扱うDiagnosticRelatedInfo(relatedInformationから関連コードを取り出す仕組み)はここでは一切呼ばれません。それどころかIDiagnosticVariablePropsには// useRelatedInfo?: boolean;という、コメントアウトされたまま使われていないフィールドが残っており、少なくとも現行コードでは、この経路にrelatedInformationを読む出口自体がまだ用意されていないことが見て取れます。

まとめると、右クリックのFix(および、選択範囲を指定せず診断を添付するかたちで呼ばれる、その他のインラインチャット全般)は、次の2段階の絞り込みを経ています。

  1. VS CodeコアのInlineChatControllerが、QuickFixesProviderの組み立てた/fix ${diagnostics}という文字列を"Fix the attached problem(s)"に差し替え、添付する診断もライブマーカーを手掛かりに選び直す
  2. Copilot拡張機能のDiagnosticVariableが、その診断からd.message・ファイルパス・行番号・code・severity(重大度)・Cookbookだけを取り出してプロンプト化する。relatedInformationは読まれない

この2段階を経る限り、たとえSARIF Viewerがステップ2でrelatedInformationにCodeFlowを詰めていたとしても、右クリックのFixには届きません。FixでCodeFlowが届きうる経路は、次の5-3で見る、別の—かつ限定的な—経路だけです。

5-3.もう一方の経路:FixIntentに到達した場合に読まれるもの

ここから見るDiagnostics/DiagnosticRelatedInfoは、5-1・5-2で追った右クリックのFixがたどる経路ではありません。右クリックのFixでは、/fix ...という文字列がInlineChatControllerで"Fix the attached problem(s)"へ差し替えられるため、/fixというslash commandとして次に説明するFixIntent経路に入ることはありません。

FixIntentが実際に呼ばれるのは、/fix ...という文字列がCopilotの意図分類まで無傷で届いたとき—典型的には、Copilot Chatのサイドパネルへ直接/fix ...と入力した場合です。

// extensions/copilot/src/extension/intents/node/fixIntent.ts (microsoft/vscode, main)
export class FixIntent implements IIntent {
	static readonly ID = Intent.Fix;
	readonly id = Intent.Fix;
	readonly locations = [ChatLocation.Editor, ChatLocation.Panel, ChatLocation.Notebook];

	async invoke(invocationContext: IIntentInvocationContext): Promise<IIntentInvocation> {
		const { location, documentContext, request } = invocationContext;
		// ...
		if (location === ChatLocation.Panel) {
			const endpoint = await this.endpointProvider.getChatEndpoint(request);
			return this.instantiationService.createInstance(GenericPanelIntentInvocation, this, location, endpoint, PanelChatFixPrompt, invocationContext.documentContext);
		}
		// location === Editor または Notebook
		const prompt = /* InlineFixNotebookPrompt または */ InlineFix3Prompt;
		return this.instantiationService.createInstance(InlineFixIntentInvocation, this, location, endpoint, prompt, documentContext, features);
	}
}

https://github.com/microsoft/vscode/.../fixIntent.ts

FixIntent.locationsにはEditor(エディタ上のインラインチャット)も含まれているため、エディタ上のインラインチャットに直接/fix ...と打ち込んだ場合もFixIntentにたどり着きます。右クリックのFixとの違いは、5-1で見た差し替えを経由するかどうかだけです。

Diagnostics/DiagnosticRelatedInfoが実際に使われるのは、パネル側はPanelChatFixPrompt、エディタ側はInlineFix3Prompt(本稿では未掲載)です。以下は両者から共通して呼ばれるDiagnosticsの実装です。ここには実はSARIF側がステップ2でrelatedInformationに何か詰めていれば、Copilotに渡せたはずの仕組みと、「Cookbook」と呼ばれるもう1つの補完の仕組みが存在します。

// extensions/copilot/src/extension/prompts/node/inline/diagnosticsContext.tsx (microsoft/vscode, main)
export class Diagnostics extends PromptElement<DiagnosticsProps> {
	async render(state: void, sizing: PromptSizing) {
		const { diagnostics, documentContext } = this.props;
		// ...
		return diagnostics.map((d, idx) => {
			const cookbook = this.fixCookbookService.getCookbook(documentContext.language.languageId, d);
			return <>
				<DiagnosticDescription diagnostic={d} cookbook={cookbook} maxLength={LINE_CONTEXT_MAX_SIZE} documentContext={documentContext} />
				{this.props.includeRelatedInfos !== false && <DiagnosticRelatedInfo diagnostic={d} cookbook={cookbook} document={documentContext.document} />}
				<DiagnosticSuggestedFix cookbook={cookbook} />
			</>;
		});
	}
}

https://github.com/microsoft/vscode/.../diagnosticsContext.tsx

DiagnosticDescriptionは、診断メッセージと診断位置周辺のコードをプロンプトへ追加します。注目すべきは、このDiagnosticsが各診断についてthis.fixCookbookService.getCookbook(languageId, d)で「Cookbook」を取得し、それをDiagnosticRelatedInfo・DiagnosticSuggestedFixの両方に渡している点です。

FixCookbookServiceはCopilot拡張機能内でサービスとして登録されており、取得したCookbookは、診断に応じてFixのプロンプトへ追加コンテキストや修正情報を補うために使われます。実際にDiagnosticRelatedInfoでは、CookbookのadditionalContext()が返した指定に応じて、診断位置や呼び出し元の定義などを追加コンテキストとして取得します。

// extensions/copilot/src/extension/prompts/node/inline/diagnosticsContext.tsx (microsoft/vscode, main)
export class DiagnosticRelatedInfo extends PromptElement<DiagnosticRelatedInfoProps> {
	private async getRelatedInfos(): Promise<DiagnosticRelatedInfoState> {
		const infos: RelatedInfo[] = [];
		const definitionRanges: Range[] = [];
		const ignoredFiles: Uri[] = [];
		const diagnostic = this.props.diagnostic;

		if (diagnostic.relatedInformation) {
			for (const relatedInformation of diagnostic.relatedInformation) {
				try {
					const location = relatedInformation.location;
					if (await this.ignoreService.isCopilotIgnored(location.uri)) {
						ignoredFiles.push(location.uri);
						continue;
					}
					const document = await this.workspaceService.openTextDocument(location.uri);
					const locationRange = location.range;
					const treeSitterAST = this.parserService.getTreeSitterAST(document);
					let relatedCodeText: string | undefined;
					if (treeSitterAST) {
						const treeSitterLocationRange = vscodeToTreeSitterRange(locationRange);
						const rangeOfInterest = await treeSitterAST.getCoarseParentScope(treeSitterLocationRange);
						relatedCodeText = document.getText(treeSitterToVSCodeRange(rangeOfInterest));
					}
					if (!relatedCodeText || relatedCodeText.length > RELATED_INFO_MAX_SIZE) {
						relatedCodeText = document.getText(locationRange);
					}
					if (relatedCodeText.length <= RELATED_INFO_MAX_SIZE) {
						infos.push({ content: relatedCodeText, uri: location.uri, range: location.range });
					}
				} catch (e) {
					// ignore
				}
			}
		}
		const definitionLocations = this.props.cookbook.additionalContext();
		for (const location of definitionLocations) {
			switch (location) {
				case ContextLocation.ParentCallDefinition:
					// tree-sitterで診断位置を含む関数呼び出し式を探し、definitionRangesに追加
					break;
				case ContextLocation.DefinitionAtLocation:
					definitionRanges.push(this.props.diagnostic.range);
					break;
			}
		}
		return { infos, definitionRanges, ignoredFiles };
	}
}

https://github.com/microsoft/vscode/.../diagnosticsContext.tsx

つまりCopilot側は、d.message+その行のコード(DiagnosticDescription)に加えて、(a) diagnostic.relatedInformationが設定されていれば、その関連位置のコードも「This diagnostic has some related code」としてプロンプトに含める、そして(b) Cookbookがその診断に対して追加の参照位置を指定していれば、その位置も追加する、という2系統の仕組みを持っています。

これは一見、SARIFのcodeFlowsが表現したいような「関連する複数箇所」を運ぶための受け皿が用意されているようにも見えます。しかし、この受け皿は今回のSARIFでは機能しません。(a)については、ステップ2で確認した通りSARIF ViewerのResultDiagnosticはrelatedInformationを一度も設定していないため、CopilotのDiagnosticRelatedInfoに渡ってくる時点でそもそも空です。(b)についても、Cookbookは5-2で見た通りsourceとcodeを手掛かりに引き当てる仕組みです。少なくとも確認した実装では、そのどちらも設定していないSARIF由来の診断で、SARIFのruleIdを頼りにしたCookbook補完を期待することはできません。

しかもDiagnosticRelatedInfoは、relatedInformation由来のinfosとCookbook由来のdefinitionRangesがどちらも空であれば、何も出力せずに終わります。SARIF由来の診断では両方が空になるため、この経路に到達しても「This diagnostic has some related code:」という見出しの行すら現れません。

さらに、PanelChatFixPromptの実装を見ると、Diagnosticsが返す関連コードと、ユーザーが/fixの後ろに書いた自由文とは、別々にプロンプトへ渡っていることも分かります。

// extensions/copilot/src/extension/prompts/node/panel/panelChatFixPrompt.tsx (microsoft/vscode, main)
export class PanelChatFixPrompt extends PromptElement<PanelChatFixPromptProps> {
	render(state: void, sizing: PromptSizing) {
		const query = this.props.promptContext.query || 'There is a problem in this code. Rewrite the code to show it with the bug fixed.';
		const getDiagnostics = ({ document, selection }: IDocumentContext) =>
			findDiagnosticForSelectionAndPrompt(this.languageDiagnosticsService, document.uri, selection, query);
		return <>
			{/* ... */}
			<Diagnostics documentContext={documentContext} diagnostics={getDiagnostics(documentContext)} />
			{/* ... */}
			<ChatVariablesAndQuery flexGrow={2} priority={900} chatVariables={chatVariables} query={query} embeddedInsideUserMessage={false} />
		</>;
	}
}

https://github.com/microsoft/vscode/.../panelChatFixPrompt.tsx

query(/fix の後ろに書いた文字列。省略時は既定の英文)はChatVariablesAndQueryとしてユーザー自身の指示文の扱いでプロンプトに載りますが、Diagnosticsが組み立てる診断コンテキストとは別枠です。つまり、/fixの後ろに長い説明文を書いても、それは「ユーザーからの追加指示」として扱われるだけで、DiagnosticRelatedInfoが読みに行くdiagnostic.relatedInformationそのものを増やすわけではありません。とはいえ、ユーザーの指示文としてはモデルに届きます。CodeFlowの内容を自分で書き添えること自体は有効で、この点は最後に改めて触れます。

まとめると、この経路に到達しても、SARIF由来の診断ではrelatedInformationもCookbookも空であるため、DiagnosticRelatedInfoやCookbookによる追加コンテキストは出力されません。残るのは、DiagnosticDescriptionが出す診断メッセージと、その位置の周辺コードです。

まとめ:入口は複数あっても、届く情報は変わらない

節の冒頭で挙げた入口のうち、パネルへの直接入力を除く3つは、実装上いずれもvscode.editorChat.startを呼びます。CodeAction(QuickFixesProvider)はステップ3-2で見たので、残る2つのコードも確認しておきます。

// extensions/copilot/src/extension/inlineChat/vscode-node/inlineChatCommands.ts (microsoft/vscode, main)
const doFix = () => {
	const activeDocument = vscode.window.activeTextEditor;
	if (!activeDocument) { return; }
	const activeSelection = activeDocument.selection;
	const diagnostics = vscode.languages.getDiagnostics(activeDocument.document.uri).filter(diagnostic => {
		return !!activeSelection.intersection(diagnostic.range);
	}).map(d => d.message).join(', ');
	return vscode.commands.executeCommand('vscode.editorChat.start', { message: `/${Intent.Fix} ${diagnostics}`, autoSend: true, initialRange: vscode.window.activeTextEditor?.selection });
};
// ...
disposables.add(vscode.commands.registerCommand('github.copilot.chat.fix', doFix));

https://github.com/microsoft/vscode/.../inlineChatCommands.ts

さらに、src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.tsには、コマンドinlineChat.fixDiagnosticsとして登録された、messageすら組み立てずattachDiagnostics: trueだけを渡すアクションもあります。

// src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts (microsoft/vscode, main)
export class FixDiagnosticsAction extends AbstractInlineChatAction {
	constructor() {
		super({ id: 'inlineChat.fixDiagnostics', /* ... */ });
	}
	override runInlineChatCommand(accessor, ctrl) {
		ctrl.run({ autoSend: true, attachDiagnostics: true });
	}
}

https://github.com/microsoft/vscode/.../inlineChatActions.ts

doFixは/fix ${diagnostics}という文字列を組み立ててvscode.editorChat.startへ渡し、FixDiagnosticsActionはmessageを組み立てずattachDiagnostics: trueだけを渡します。作りは違いますが、5-1で見た通りこの呼び出しは必ずInlineChatController#runZoneを通り、attachDiagnosticsは既定でtrueになります。したがって文字列を自前で組み立てていても、組み立てていなくても結果は同じ—arg.messageは"Fix the attached problem(s)"に上書きされ、実際にモデルへ渡るのは、マーカーを手掛かりに選び直した診断をDiagnosticVariableがレンダリングした診断メッセージと位置だけです。

Fixがこの介入を経ずにFixIntent/DiagnosticRelatedInfoへ届くのは、本稿で確認した範囲では、Copilot Chatパネルへ/fix ...と直接入力した場合(ChatLocation.Panel)です。ただしPanelChatFixPromptはfindDiagnosticForSelectionAndPromptでアクティブな文書・選択範囲・クエリから診断を探すため、FixIntentに到達したからといって、目的のSARIFの指摘が必ず診断として渡るわけではありません。FixIntent.locationsにはChatLocation.Editorも含まれているため、エディタのインラインチャットへ直接/fix ...と打ち込むケースも該当します。一方、メニューから「Fix」を選ぶ操作はどれもvscode.editorChat.startを経由するため、実質的には入力欄へ自分で打ち込んだ場合だけが到達経路になります。

表にすると次の通りです。

入口最終的に呼ぶAPICopilotの意図分類への到達実際にモデルへ渡る診断情報
右クリックのFix/github.copilot.chat.fix/inlineChat.fixDiagnosticsvscode.editorChat.start通常は到達しない(InlineChatControllerがmessageを差し替えるため)DiagnosticVariable:診断メッセージ+位置(relatedInformationは読まれず、Cookbookも空)
Copilot Chatパネルへ/fix ...と直接入力(vscode.editorChat.startを経由しない)到達する:FixIntent(ChatLocation.Panel)Diagnostics:診断メッセージ+診断位置の周辺コード(DiagnosticRelatedInfoは何も出力しない)

同じ「Fix」でも、どこから呼び出したかによってたどるコードパスはまったく違います。届く情報にも差があり、DiagnosticVariableが書き出すのは診断メッセージと位置・code・severity(重大度)で、Diagnosticsのほうは診断位置の周辺コードまで含めます。しかしどちらの経路であっても、SARIFのcodeFlowsやlocations[]の2番目以降は入りません

注意

今回確認したFix/Explainの処理経路では、SARIF Viewer固有の.resultや、SARIFのcodeFlows・locations[ ]などの複数要素を読み取り、診断コンテキストへ追加する処理は確認できませんでした。なお、microsoft/vscodeリポジトリ全体にはSARIFに関するコードや参照が存在するため、本稿の記述はFix/Explainに関係する処理経路に限定したものです。


この連載の記事一覧

連載|静的解析×AIエージェントを始めよう!

第2回でDiagnosticへの変換で情報が削られることを確認し、第3回ではVS CodeのIMarkerData経由でCopilotが実際に受け取るのはmessageと周辺コードのみで、SARIF固有の.resultやcodeFlowsは渡らないことを追いました。次回は後編として、SARIF Viewerが装飾するStep表示とcodeFlowsの実行順序情報がCopilot推論に反映されない実態を検証します。

このコラムの著者

株式会社ユビキタスAI

エンベデッド第3部 セールス&PM セクション

藤江 克彦​(ふじえ かつひこ)

より詳しく技術や​関連製品について​知りたい方へ

本コラムに関係する技術や関連する製品について知りたい方は、お気軽にご相談ください。


製品情報

高精度静的解析ツール

CodeSonar

世界トップレベルの解析能力で不具合や脆弱性の原因となるソースコードのバグを検出
製品ページを見る
連載

連載|静的解析×AIエージェントを始めよう! 第2回 GitHub Copilot×SARIFの留意点(前編)

コラムを読む
連載

連載|静的解析×AIエージェントを始めよう! 第1回 SARIFを使う

コラムを読む

CWE-1000は脆弱性じゃない⁉ CWEが直感的に分かりにくい理由

コラムを読む
メニューを閉じる
一つ前に戻る