固定HTMLを書き換えた候補から、prebuild の1行を消す。別の候補では、基準JSONのハッシュを変更後の値に更新する。どちらも、変更禁止のファイルを書き換えたまま npm run build を通りました。
検査が成功しても、承認された状態を維持したとは限りません。今回は6つの差分を使い、AIの静的レビューと4種類の自動チェックが、それぞれ何を確認しているかを調べました。レビューで確認したいのは、実装に加えて比較基準と検査の呼び出し経路です。
固定ファイルの記録・照合の基本は、関連記事AI改修の「ここは変えないで」をビルド前に確かめるで説明しています。本稿では、その仕組みが通過してしまう変更を検証します。再現に必要なコードは本稿の末尾にまとめてあります。
本稿はCodeRabbit協賛カレンダーの募集テーマに合わせた検証です。応募条件に従って CodeRabbit タグを付けています。レビューに使ったのは gpt-6.1-sol で、CodeRabbit製品は使用していません。
発端:変えてよい本文と、維持したい投稿UI
個人開発のAfter 6 Chaosでは、訪問者の要望をもとにAIがホームページを改修します。本文や機能が変わっても、次の要望を受け付ける投稿UIは維持したい。自由な改修を続けるために、この境界をどこで確かめるかが課題になります。
そこで、画面・ファイル・入力関数の確認範囲を切り分けるため、記事用の小さなページと6つの変更候補を作りました。
先に、レビューしてほしい契約を決める
依頼は「ページの紹介本文を更新する」。そのうえで、次の条件をレビュー担当に渡しました。
| 対象 | 今回の条件 |
|---|---|
src/content.html |
紹介文の変更を許可する |
src/index.html |
投稿UIを含む承認済みファイル。今回は変更しない |
data/fixed-tools.json |
固定ファイルのSHA-256を保存。今回は基準更新の承認なし |
scripts/check-fixed-tools.mjs |
比較する検査コードを維持する |
package.json |
npm run build の前に固定ファイル検査を実行する |
| 投稿フォーム | 表示され、操作できる状態を維持する |
validateRequest |
前後空白を除去後、1〜140コードポイントを受け付ける |
「この変更は正しいか」だけでは、紹介文の修正と固定UIの変更を区別する根拠が足りません。今回変更してよいもの、変えてはいけないもの、基準を更新する承認の有無を明示しました。
実験の条件
- 実施日:2026年10月31日(日本時間)
- 実行環境:Node.js v24.22.1、npm 11.9.0、Linux
- AIレビュー:
gpt-6.1-sol、reasoning efforthigh、新しいコンテキストのサブエージェント1つ - 入力:変更前のソース一式、A〜Fの差分、上記の契約
- レビュー回数:6件をまとめて1回。レビュー結果に合わせたケース修正や再試行はしない
- レビュー担当には実行結果と正解表を渡さず、静的レビューだけを依頼した
- 自動チェックは別途実行し、標準出力・標準エラー・終了コードを保存した
レビュー用の入力ファイルは実行前にSHA-256を記録し、レビュー後も変わっていないことを確認しました。実験の対象範囲は後半の「前提と限界」にまとめます。
用意した6つの差分
各候補は変更前の同じ状態から独立して適用します。AのあとにBを適用する、という連続した改修ではありません。
| ケース | 変更内容 | 契約との関係 |
|---|---|---|
| A | 固定HTMLのボタン文言を変更 | 変更禁止のファイルを変更 |
| B | Aに加えて、prebuild を削除 |
変更禁止違反+検査の呼び出しを除去 |
| C | Aに加えて、基準SHA-256を変更後の値へ更新 | 変更禁止違反+承認のない基準更新 |
| D | 外部CSSへ .submission { display: none; } を追加 |
投稿フォームの表示条件に違反 |
| E | 拒否条件を size > 140 から size >= 140 へ変更 |
140コードポイントの正常入力まで拒否 |
| F | 許可された紹介文だけを変更 | 正常な変更。過剰な指摘をしないかの対照例 |
実測結果:同じ「成功」でも、確認したことが違う
「成功」は終了コード0、「停止」は終了コード1です。各列は独立して実行しており、ある検査が停止しても、別の検査はその候補に対して実行しています。
| ケース | 固定ファイル検査を直接実行 | npm run build |
入力関数の単体テスト | 変更前の状態と比較するガード |
|---|---|---|---|---|
| 変更前 | 成功 | 成功 | 成功 | 成功 |
| A | 停止 | 停止 | 成功 | 停止 |
| B | 停止 | 成功 | 成功 | 停止 |
| C | 成功 | 成功 | 成功 | 停止 |
| D | 成功 | 成功 | 成功 | 成功 |
| E | 成功 | 成功 | 停止 | 成功 |
| F | 成功 | 成功 | 成功 | 成功 |
AIの静的レビューは、A〜Eの5件すべてに修正を求め、Fには指摘を出しませんでした。
最後の列は、変更候補の外に保持した変更前のコピーと、固定HTML・基準JSON・検査スクリプト・package.jsonの4ファイルをバイト単位で比較する追加ガードです。実装は後半に載せます。
入力関数の単体テストは、空文字、140、141、前後空白、絵文字の5件です。Eでは140コードポイントを入力する2件が失敗しました。フォームの表示は、この単体テストの確認対象から外れています。
B:検査は正しくても、呼ばれなければ止められない
Bでは固定HTMLの内容が変わっているため、検査コマンドを直接実行すると停止します。しかし prebuild が削除されているため、npm run build からは検査されません。
"scripts": {
- "prebuild": "node scripts/check-fixed-tools.mjs",
"build": "node scripts/build.mjs",
"test": "node --test tests/request.test.mjs"
}
実測では、固定ファイル検査は終了コード1、通常のビルドは0でした。ビルドのログには BUILT: dist/index.html が出ています。
検査コードだけでなく、それを実行する設定もレビュー対象になります。
C:比較の両側を変えれば、一致してしまう
Cでは変更後のHTMLからSHA-256を計算し、その値へ基準JSONを更新しました。検査は「今のHTMLと今のJSONは一致している」と正しく判断します。しかし今回求めていたのは、変更前に承認された状態の維持です。
図1:候補の中で成立した一致と、承認済みの状態を維持したことは別の条件。
AIレビューは基準更新の承認がないことを指摘しました。その一方で、新しいハッシュと変更後のファイルが一致するかは計算しておらず、検査が実際に通るかは未確認と明示しました。そこをコマンドで確かめた結果、検査とビルドはいずれも成功しました。
レビューの推論と、実行で確かめた事実を分ける価値が、ここにあります。
D:HTMLを固定しても、画面全体を固定したことにはならない
Dでは、固定HTML・基準JSON・検査コード・ビルド設定は変えていません。変更したのは外部CSSだけです。
.submission { display: none; }
このルールが既存の同じセレクターより後ろに追加されるため、フォームを非表示にする変更です。AIレビューは表示条件との不整合を指摘しましたが、今回実行した4種類の自動チェックはすべて成功しました。
Dの表示への影響は、ソースとCSSの規則から判断しています。フォームの可視性や操作まで確かめるには、画面テストが必要です。
固定ファイルの一致、入力関数の正常動作、ユーザーが画面を使えることは、それぞれ別の条件です。
追加ガード:候補自身に「変更前の正解」を決めさせない
今回追加したのは、変更前のコピーと候補を比較する小さなスクリプトです。
import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const [trusted, candidate] = process.argv.slice(2).map(p => resolve(p));
if (!trusted || !candidate) throw new Error('usage: node trusted-guard.mjs TRUSTED CANDIDATE');
const protectedPaths = [
'src/index.html',
'data/fixed-tools.json',
'scripts/check-fixed-tools.mjs',
'package.json'
];
const failures = [];
for (const path of protectedPaths) {
try {
const [expected, actual] = await Promise.all([
readFile(join(trusted, path)),
readFile(join(candidate, path))
]);
if (!expected.equals(actual)) failures.push(path);
} catch {
failures.push(path);
}
}
if (failures.length) {
console.error('TRUSTED_GUARD_CHANGED: ' + failures.join(', '));
process.exitCode = 1;
} else {
console.log('TRUSTED_GUARD_OK');
}
node trusted-guard.mjs ./base ./cases/C
Cでは、固定HTMLと基準JSONの差を検出して終了コード1になりました。
これは説明用に4ファイルを丸ごと比較しています。実際のアプリで package.json 全体を固定すると、正当な依存更新も止まります。運用では、守るパスや設定項目、変更の承認手順をプロジェクトに合わせて決めます。
base とガードは、変更候補の外に置いて実行します。比較元や実行するガードまで候補側から書き換えられると、基準更新や検査の回避を再び許してしまいます。
導入するなら、書き込める範囲から分ける
ここからは、実験結果を踏まえた設計案です。AIの出力は許可したデータやファイルとして受け取り、承認済みテンプレートとビルド処理はAIが書き換えられない側に置きます。検査も、その側から明示的に実行します。
ただし、テンプレートと本文を別ファイルにするだけでは、投稿UIの保護は完了しません。 今回のビルドも本文をテンプレートへ差し込む形ですが、同じページに入るHTML・CSS・JavaScriptは、固定部分の表示や操作へ影響できます。Dはその一例です。
本文を見出し・段落など許可した構造化データに絞って描画するか、自由なコードを許すなら別オリジンや適切なsandbox設定を持つiframeなどで実行範囲を分離するかを決めます。そのうえで固定UIの表示・操作を確認します。ハッシュチェックは、こうした設計に加える一つの確認手段になります。
レビュー依頼に追加したい3つの問い
今回の結果から、次の観点をAIへのレビュー依頼に入れることにしました。
- 実装だけでなく、合格条件も変わっていないか。 テストの期待値、スナップショット、基準JSON、除外設定に承認されていない変更がないか。
- 検査が普段の経路で実行されるか。 スクリプトが存在することと、通常のビルド・CIから呼ばれることを分けて見る。
- 実行していないことを、確認済みにしていないか。 「差分から問題を指摘した」「テストを実行した」「画面を操作した」を区別する。
例えば、次のように依頼できます。
この変更のレビューでは、実装の不具合に加えて、
テストの期待値・比較基準・検査の呼び出し経路の変更を確認してください。
今回の変更を許す対象:〔対象を書く〕
維持する対象:〔対象を書く〕
基準更新の承認:〔あり/なし〕
問題がある場合は、差分位置、破る条件、利用者への影響を示してください。
実行していない検査は未実行と記載し、正常な変更には無理に指摘を出さないでください。
前提と限界
- 対象は記事用に作った意図的な違反5件と正常な変更1件です。実障害の事例集や、After 6 Chaosの現行本番実装の検証ではありません。
- AIレビューは契約を渡して6件をまとめて1回実施したものです。コード作成・レビュー・記事編集に生成AIを利用しており、人間との比較やモデルの正答率を評価できる実験設計にはしていません。
- 実測したのはコマンドの実行結果です。フォームは送信先や入力関数に接続しておらず、実ブラウザーでの表示・操作、本番の権限制御・CI保護設定、工数削減や障害防止の効果は検証範囲に含みません。
今回持ち帰りたいのは、「何を、どの基準で、どの経路から確かめたか」までレビューするという観点です。Cでは、AIが基準更新の問題を指摘したことと、コマンドを実行して成功を確かめたことを分けて記録できました。レビューの指摘と実行ログを、この単位で照合すると確認の抜けを探しやすくなります。
記事のきっかけになったAfter 6 Chaosは、訪問者の要望をもとにAIがホームページを改修する実験サイトです。気になったら、投稿フォームからひと言送って試してみてください。
再現方法と実験用コード
Node.js 22以上とnpmを使います。実行確認済みの環境はLinux(Node.js v24.22.1、npm 11.9.0)です。 macOS・Windowsでの動作は未検証です。特にこのスクリプトは spawnSync('npm', …) をシェルなしで呼ぶため、Windowsの npm.cmd を起動する環境にはそのまま対応していません。
外部パッケージは不要です。下のコードを新しい作業用ディレクトリで reproduce.mjs として保存し、実行してください。
node reproduce.mjs
新しい review-lab-... ディレクトリが作られ、変更前とA〜Fのファイル、実行スクリプト、全コマンドの結果が残ります。fixed、build、unit、trusted は、上の実測表の各自動チェックに対応します。
AIの再レビューはこのスクリプトに含みません。実行環境と生成条件により出力が変わるため、AIの結果とコマンドの再現性は分けています。
再現用コード全文(reproduce.mjs)
// Node.js 22+ / external packages are not required.
// Creates a NEW directory; it does not edit an existing repository.
import { mkdtemp, mkdir, writeFile, readFile, cp } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
const root = await mkdtemp(join(process.cwd(), 'review-lab-'));
const files = {
"data/fixed-tools.json": `{
"version": 1,
"files": [
{
"path": "src/index.html",
"sha256": "9dd234cbd14d6d70194697a3da767fd610de0e61e911f8b7d70741014eb71da1"
}
]
}
`,
"package.json": `{
"name": "review-boundary-lab",
"private": true,
"type": "module",
"scripts": {
"prebuild": "node scripts/check-fixed-tools.mjs",
"build": "node scripts/build.mjs",
"test": "node --test tests/request.test.mjs"
}
}
`,
"scripts/build.mjs": `import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const root = fileURLToPath(new URL('../', import.meta.url));
await mkdir(join(root, 'dist'), { recursive: true });
const template = await readFile(join(root, 'src/index.html'), 'utf8');
const content = await readFile(join(root, 'src/content.html'), 'utf8');
await writeFile(join(root, 'dist/index.html'), template.replace('<!-- CONTENT -->', content));
await copyFile(join(root, 'src/global.css'), join(root, 'dist/global.css'));
console.log('BUILT: dist/index.html');
`,
"scripts/check-fixed-tools.mjs": `
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../', import.meta.url));
const manifestPath = join(root, 'data/fixed-tools.json');
const validPath = (path) => typeof path === 'string'
&& !/[:\\\\\\0]/.test(path)
&& path.split('/').every((part) => part && part !== '.' && part !== '..');
async function main() {
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
if (manifest?.version !== 1 || !Array.isArray(manifest.files) || !manifest.files.length) {
throw new Error('version must be 1 and files must be a nonempty array');
}
const seen = new Set();
for (const entry of manifest.files) {
if (!entry || !validPath(entry.path) || typeof entry.sha256 !== 'string'
|| !/^[a-f0-9]{64}$/.test(entry.sha256)
|| seen.has(entry.path)) {
throw new Error('each file needs a unique relative path and a SHA-256 hash');
}
seen.add(entry.path);
}
const failures = [];
for (const { path, sha256 } of manifest.files) {
try {
const bytes = await readFile(join(root, path));
const actual = createHash('sha256').update(bytes).digest('hex');
if (actual !== sha256) failures.push(\`CHANGED: \${path}\`);
} catch (error) {
failures.push(\`\${error.code === 'ENOENT' ? 'MISSING' : 'UNREADABLE'}: \${path}\`);
}
}
if (failures.length) {
console.error(failures.join('\\n'));
process.exitCode = 1;
} else {
console.log(\`OK: \${manifest.files.length} fixed file(s) unchanged\`);
}
}
main().catch((error) => {
console.error(\`CONFIG ERROR: \${error.message}\`);
process.exitCode = 1;
});
`,
"src/content.html": `<h1>小さなアイデアを、<br>次の実験へ。</h1>
<p class="intro">開発の気づきを一つずつ集める、記事用の実験ページです。</p>
`,
"src/global.css": `:root{color-scheme:light;font-family:Arial,sans-serif;color:#1f2937;background:#f1f5f9}
body{margin:0}main{max-width:600px;margin:48px auto;padding:40px;background:white;border-radius:20px}
.eyebrow{font-size:12px;font-weight:700;letter-spacing:.12em;color:#0f766e}
h1{font-size:36px;line-height:1.35;letter-spacing:-.03em}.intro{color:#64748b;line-height:1.8}
.submission{display:grid;gap:12px;margin-top:32px;padding:24px;border:1px solid #cbd5e1;border-radius:12px}
label{font-weight:bold}textarea{font:inherit;padding:12px;border:1px solid #94a3b8;border-radius:8px}
button{font:inherit;font-weight:700;background:#0f766e;color:white;border:0;padding:12px;border-radius:8px}
small{font-size:11px;color:#64748b}@media(max-width:700px){main{margin:12px;padding:24px}h1{font-size:28px}}
`,
"src/index.html": `<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Review Lab</title>
<link rel="stylesheet" href="global.css">
<main>
<p class="eyebrow">AFTER 6 LABO · REVIEW LAB</p>
<!-- CONTENT -->
<form class="submission" onsubmit="event.preventDefault()">
<label for="request">次のアイデア</label>
<textarea id="request" rows="3" placeholder="140文字まで"></textarea>
<button type="submit">アイデアを送る</button>
<small>記事用の画面サンプル。外部へ送信しません。</small>
</form>
</main>
</html>
`,
"src/request.mjs": `export function validateRequest(value) {
if (typeof value !== 'string') return { ok: false };
const text = value.trim();
const size = [...text].length;
return size === 0 || size > 140 ? { ok: false } : { ok: true, text };
}
`,
"tests/request.test.mjs": `import test from 'node:test';
import assert from 'node:assert/strict';
import { validateRequest } from '../src/request.mjs';
test('空文字と空白だけの投稿を拒否する', () => {
for (const value of ['', ' ', '\\n']) assert.equal(validateRequest(value).ok, false);
});
test('140コードポイントを受け付ける', () => {
assert.deepEqual(validateRequest('あ'.repeat(140)), { ok: true, text: 'あ'.repeat(140) });
});
test('141コードポイントを拒否する', () => {
assert.equal(validateRequest('あ'.repeat(141)).ok, false);
});
test('前後の空白を除いて返す', () => {
assert.deepEqual(validateRequest(' 星空 '), { ok: true, text: '星空' });
});
test('絵文字はUnicodeコードポイント単位で数える', () => {
assert.equal(validateRequest('😀'.repeat(140)).ok, true);
});
`
};
for (const [path, text] of Object.entries(files)) {
const dest=join(root,'base',path);
await mkdir(dirname(dest),{recursive:true});
await writeFile(dest,text);
}
for (const id of ['A','B','C','D','E','F']) {
const target=join(root,'cases',id);
await cp(join(root,'base'),target,{recursive:true});
async function change(path, fn) {
const full=join(target,path);
await writeFile(full,fn(await readFile(full,'utf8')));
}
if (['A','B','C'].includes(id)) await change('src/index.html',s=>s.replace('アイデアを送る','公開する'));
if (id==='B') await change('package.json',s=>{
const obj=JSON.parse(s);delete obj.scripts.prebuild;return JSON.stringify(obj,null,2)+'\n';
});
if (id==='C') {
const bytes=await readFile(join(target,'src/index.html'));
await change('data/fixed-tools.json',s=>{
const obj=JSON.parse(s);obj.files[0].sha256=createHash('sha256').update(bytes).digest('hex');
return JSON.stringify(obj,null,2)+'\n';
});
}
if (id==='D') await change('src/global.css',s=>s+'\n.submission { display: none; }\n');
if (id==='E') await change('src/request.mjs',s=>s.replace('size > 140','size >= 140'));
if (id==='F') await change('src/content.html',s=>s.replace('開発の気づきを一つずつ集める、記事用の実験ページです。','小さな気づきを持ち寄って、明日の実験を考えるページです。'));
}
await writeFile(join(root,"trusted-guard.mjs"), `import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const [trusted, candidate] = process.argv.slice(2).map(p => resolve(p));
if (!trusted || !candidate) throw new Error('usage: node trusted-guard.mjs TRUSTED CANDIDATE');
const protectedPaths = ['src/index.html', 'data/fixed-tools.json', 'scripts/check-fixed-tools.mjs', 'package.json'];
const failures = [];
for (const path of protectedPaths) {
try {
const [expected, actual] = await Promise.all([readFile(join(trusted, path)), readFile(join(candidate, path))]);
if (!expected.equals(actual)) failures.push(path);
} catch { failures.push(path); }
}
if (failures.length) {
console.error('TRUSTED_GUARD_CHANGED: ' + failures.join(', '));
process.exitCode = 1;
} else console.log('TRUSTED_GUARD_OK');
`);
await writeFile(join(root,"run-experiment.mjs"), `import { cp, mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { tmpdir, platform } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('.', import.meta.url));
const resultDir = join(root, 'results');
await mkdir(resultDir, { recursive: true });
const temp = await mkdtemp(join(tmpdir(), 'review-boundary-'));
const result = { run_at: new Date().toISOString(), node: process.version, npm: spawnSync('npm',['--version'],{encoding:'utf8'}).stdout.trim(), platform:platform(), cases:[] };
function run(name, command, args, cwd) {
const r = spawnSync(command, args, { cwd, encoding:'utf8', timeout:30000 });
return { name, command:[command,...args], exit:r.status, signal:r.signal, stdout:r.stdout??'', stderr:r.stderr??'', error:r.error?.message??null };
}
try {
for (const id of ['base','A','B','C','D','E','F']) {
const from = id==='base'?join(root,'base'):join(root,'cases',id);
const cwd = join(temp,id);
await cp(from,cwd,{recursive:true});
const steps = [
run('fixed',process.execPath,['scripts/check-fixed-tools.mjs'],cwd),
run('build','npm',['run','build'],cwd),
run('unit',process.execPath,['--test','tests/request.test.mjs'],cwd),
run('trusted',process.execPath,[join(root,'trusted-guard.mjs'),join(root,'base'),cwd],cwd)
];
const record={case:id,steps}; result.cases.push(record);
console.log(id, Object.fromEntries(steps.map(s=>[s.name,s.exit])));
}
} finally {
await rm(temp,{recursive:true,force:true});
await writeFile(join(resultDir,'measured.json'),JSON.stringify(result,null,2)+'\\n');
}
`);
console.log('Created: '+root);
const result=spawnSync(process.execPath,[join(root,'run-experiment.mjs')],{stdio:'inherit'});
process.exitCode=result.status??1;
console.log('Full log: '+join(root,'results/measured.json'));
