@takagaki/cortex-decisions-viewer 0.12.14 → 0.12.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.js +7 -274
- package/dist/meet-guide-images.js +29 -0
- package/dist/render.js +182 -131
- package/dist/repo.js +585 -0
- package/package.json +1 -1
package/dist/repo.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AI_HISTORY_MAX_ENTRIES = void 0;
|
|
3
4
|
exports.resolveRepo = resolveRepo;
|
|
4
5
|
exports.latestGeneratedPaths = latestGeneratedPaths;
|
|
5
6
|
exports.normalizeToHttps = normalizeToHttps;
|
|
7
|
+
exports.classifyCommit = classifyCommit;
|
|
8
|
+
exports.isFrontmatterKeyRename = isFrontmatterKeyRename;
|
|
9
|
+
exports.collectAiChangeHistory = collectAiChangeHistory;
|
|
6
10
|
const node_child_process_1 = require("node:child_process");
|
|
7
11
|
/**
|
|
8
12
|
* GitHubのowner/repoを表すベースURLを解決する。
|
|
@@ -102,3 +106,584 @@ function normalizeToHttps(url) {
|
|
|
102
106
|
}
|
|
103
107
|
return null;
|
|
104
108
|
}
|
|
109
|
+
// ---- AIによるレコードの変更履歴(git 由来)------------------------------------
|
|
110
|
+
//
|
|
111
|
+
// レコード詳細の「AIによる最近の変更」の出どころ。**以前は週次整理レポート
|
|
112
|
+
// (.cortex/weekly-curation-report.json の actions)を読んでいたが、あれは毎回全置換で、
|
|
113
|
+
// 翌週の実行でレポートが差し替わると前の履歴が画面から消えた**(オーナーが実際に踏んだ)。
|
|
114
|
+
// 「黙って書き換えられた記録を作らない」という節の目的に対して、1週間で消える履歴は
|
|
115
|
+
// 用を成さない。git のコミットは消えないので、そちらを正本にする。
|
|
116
|
+
//
|
|
117
|
+
// **git の呼び出しはレコード数に依存させない。ビルド1回につき2回だけ呼ぶ。**
|
|
118
|
+
// レコードごとに git log を回すと、案件が育つほどビルドが線形に遅くなる。
|
|
119
|
+
//
|
|
120
|
+
// パス1: --name-status … コミットの素性(誰が・いつ・何を言って)と触ったパスだけ。出力は軽い
|
|
121
|
+
// パス2: -p -U0 --diff-filter=M … **変更(M)の差分だけ**。追加(A)の差分は取らない
|
|
122
|
+
// ——追加時の「変更後」はレコード本文そのもので、
|
|
123
|
+
// 同じページに全文が既に出ている(site の JSON が二重になる)
|
|
124
|
+
//
|
|
125
|
+
// 例外は全部握って空を返す(latestGeneratedPaths と同じ規律)。浅い clone・git 無し・
|
|
126
|
+
// メッセージ規約の変更 → 「履歴の節が出ない」だけで、他の表示は壊れない。
|
|
127
|
+
/** ファイル節の始まりの印 */
|
|
128
|
+
const DIFF_HEADER = "diff --" + "git ";
|
|
129
|
+
/** 行頭に置いてコミットの境目を見分ける印。差分もパスも NUL では始まらない */
|
|
130
|
+
const NUL = "\u0000";
|
|
131
|
+
/** コミット1行の中の区切り(Unit Separator)。コミットメッセージに現れない制御文字を選ぶ */
|
|
132
|
+
const US = "\u001f";
|
|
133
|
+
/** 遡るコミット数の上限。深追いしてもビルドが遅くなるだけで、画面に出るのはレコードごとに数件 */
|
|
134
|
+
const AI_HISTORY_MAX_COMMITS = 400;
|
|
135
|
+
/** レコードごとに画面へ渡す件数(分類B・2026-08-29)。折りたたみの中でも1件が差分を持つので絞る */
|
|
136
|
+
exports.AI_HISTORY_MAX_ENTRIES = 5;
|
|
137
|
+
/** 変更前・変更後それぞれの文字数の上限。超えたら丸めて truncated を立てる */
|
|
138
|
+
const AI_HISTORY_MAX_DIFF_CHARS = 2000;
|
|
139
|
+
/** レコードのパスの形。README・雛形はレコードではない */
|
|
140
|
+
const RECORD_PATH = /^Cortex\/[^/]+\/records\/[^/]+\.md$/;
|
|
141
|
+
/**
|
|
142
|
+
* 下の pathspec が git 側で拾う範囲。**`RECORD_PATH` より広い。**
|
|
143
|
+
* git の pathspec の `*` は `/` をまたぐので、`Cortex/A/records/sub/x.md` のような
|
|
144
|
+
* 入れ子も git は選ぶ。**パス2へ渡す件数はこちらで数える**——狭いほうで数えると
|
|
145
|
+
* 窓が浅くなり、**窓の端の本物の変更が黙って消える**(レビューで指摘された潜在の欠陥)。
|
|
146
|
+
*/
|
|
147
|
+
const PATHSPEC_MATCH = /^Cortex\/.*\/records\/.*\.md$/;
|
|
148
|
+
/** 探索範囲(git の pathspec)。README や Context 配下の差分は最初から出力に入れない */
|
|
149
|
+
const RECORD_PATHSPEC = "Cortex/*/records/*.md";
|
|
150
|
+
function classifyCommit(id) {
|
|
151
|
+
const subject = id.subject || "";
|
|
152
|
+
// 1. 人の操作の代理(infra/lambda/intake/logic.mjs の promoteCommitMessage / deleteCommitMessage)
|
|
153
|
+
if (/^(Gold確認|レコード削除)[::]/.test(subject))
|
|
154
|
+
return "human";
|
|
155
|
+
// 2. データ構造の引っ越し。実データの件名は「エンジンマイグレーションを適用(schema_version更新)」。
|
|
156
|
+
//
|
|
157
|
+
// 🔴 **素の部分一致にしない(頭に錨を打ち、bot 名義とのANDにする)。** 夜間パイプラインの件名は
|
|
158
|
+
// **レコードの見出しを埋め込む**(実データ: 「用語集にPMプレイブック・オーケストレーター・
|
|
159
|
+
// keystoneをdraftで追加」)。素の部分一致だと、「スキーマ移行」「DBマイグレーション」という
|
|
160
|
+
// **用語が1つ登録された晩**に、そのコミットが起票したレコードが全部(実データで最大29件)
|
|
161
|
+
// 履歴から消える——しかも消えたことは画面に出ない。下の規則3が同じ理由で接頭辞に限っている。
|
|
162
|
+
if (looksLikeBot(id) && /^(エンジン)?マイグレーション|^スキーマ移行|^schema[_-]?version|^(apply\s+)?[a-z ]*migration/i.test(subject)) {
|
|
163
|
+
return "migration";
|
|
164
|
+
}
|
|
165
|
+
// 2. 夜間パイプライン(cortex-engine)の規約。**コロンより前(接頭辞)だけを見る。**
|
|
166
|
+
// 素の部分一致にすると「自動生成された用語を手で直した」「週次整理の取りこぼしを補完」
|
|
167
|
+
// のような**人が書いた件名**まで AI に倒れ、顧客の画面に「AIがこの記録を書き換えました」と
|
|
168
|
+
// 出る(この関数が防ごうとしている誤帰属の、ちょうど逆向き)。
|
|
169
|
+
// 規約側は「Gold自動追記: …」「週次整理: …」の形なので、接頭辞で足りる。
|
|
170
|
+
//
|
|
171
|
+
// ⚠️ **見るのは件名(%s)だけ。** latestGeneratedPaths(このファイル上部)の
|
|
172
|
+
// `--grep` は**メッセージ本文も**対象なので、判定の範囲が揃っていない。
|
|
173
|
+
// 本文まで足すと複数行になって1行1コミットのパースが壊れるため、ここは件名に留める。
|
|
174
|
+
// 件名に規約が無く本文にだけある形で engine が回っても、**規則3(bot 名義)で拾える**
|
|
175
|
+
// ——夜間パイプラインは GitHub Actions の bot 名義でコミットするため。
|
|
176
|
+
const scope = /^([^::]{1,40})[::]/.exec(subject);
|
|
177
|
+
if (scope && /自動追記|自動生成|週次整理/.test(scope[1]))
|
|
178
|
+
return "ai";
|
|
179
|
+
// 4. bot 名義
|
|
180
|
+
return looksLikeBot(id) ? "ai" : "human";
|
|
181
|
+
}
|
|
182
|
+
/** 夜間パイプラインの名義(GitHub Actions の bot) */
|
|
183
|
+
function looksLikeBot(id) {
|
|
184
|
+
return /\[bot\]|github-actions/i.test(`${id.name || ""} ${id.email || ""}`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 🔴 **フロントマターの「項目名だけ」が変わった差分か。**(purpose に依存しない構造の規則)
|
|
188
|
+
*
|
|
189
|
+
* 消えた行と増えた行が**すべて `key: value` の形**で、**値の集合が変わっていない**なら、
|
|
190
|
+
* それは項目名の付け替え=データ構造の引っ越しであって、記録の内容の変化ではない。
|
|
191
|
+
* 実データの `summary: "X"` → `description: "X"` はこれで落ちる。件名の書き方が
|
|
192
|
+
* 変わっても落ちるので、上の件名の規則の**裏取り**になる。
|
|
193
|
+
*
|
|
194
|
+
* 一方 `definition: 旧` → `definition: 新`(本物の精製)は**値が変わる**ので残る。
|
|
195
|
+
*
|
|
196
|
+
* **片側だけの変更(追加だけ・削除だけ)は対象にしない。** 「フロントマターに項目が
|
|
197
|
+
* 1つ増えた」を機械的に落とすと、AIが definition や aliases を足した**本物の精製まで消える**。
|
|
198
|
+
* 実データの「`status:` 行の後付け」はこの規則では落とさず、件名の規則に任せる——
|
|
199
|
+
* **構造の規則は「内容が変わっていないと証明できるもの」だけに限る。**
|
|
200
|
+
*/
|
|
201
|
+
// ⚠️ 名前は「フロントマターの」だが、**見ているのは差分の行だけで、その行が
|
|
202
|
+
// frontmatter の内側かどうかは判定していない**(差分だけでは境界が分からない)。
|
|
203
|
+
// 本文に `key: value` の形の行があり、値を保ったままキーだけ変える変更は落ちる。
|
|
204
|
+
// 実データではその形は1件も無いが、そういう規則だと承知して使うこと。
|
|
205
|
+
function isFrontmatterKeyRename(before, after) {
|
|
206
|
+
const lines = (t) => t.split("\n").filter((l) => l.trim() !== "");
|
|
207
|
+
const bs = lines(before);
|
|
208
|
+
const as = lines(after);
|
|
209
|
+
if (!bs.length || !as.length)
|
|
210
|
+
return false;
|
|
211
|
+
const KEY_VALUE = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/;
|
|
212
|
+
const parse = (arr) => {
|
|
213
|
+
const keys = [];
|
|
214
|
+
const values = [];
|
|
215
|
+
for (const l of arr) {
|
|
216
|
+
const m = KEY_VALUE.exec(l);
|
|
217
|
+
if (!m)
|
|
218
|
+
return null;
|
|
219
|
+
keys.push(m[1]);
|
|
220
|
+
values.push(m[2].trim());
|
|
221
|
+
}
|
|
222
|
+
return { keys, values };
|
|
223
|
+
};
|
|
224
|
+
const b = parse(bs);
|
|
225
|
+
const a = parse(as);
|
|
226
|
+
if (!b || !a)
|
|
227
|
+
return false;
|
|
228
|
+
const same = (x, y) => x.length === y.length && [...x].sort().join("\u0000") === [...y].sort().join("\u0000");
|
|
229
|
+
// 値が同じで、項目名が変わっている=付け替え
|
|
230
|
+
return same(b.values, a.values) && !same(b.keys, a.keys);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 改名の検出。**90% 一致以上を改名とみなす。**
|
|
234
|
+
*
|
|
235
|
+
* 100%(完全一致だけ)も検討したが、**実データで唯一の「本物の改名」を取りこぼす**ことが
|
|
236
|
+
* 分かったのでやめた。カナリアの全履歴を数えた結果:
|
|
237
|
+
*
|
|
238
|
+
* 50% → 117件 / 90% → 117件 / 100% → 116件
|
|
239
|
+
* 100% だけが落とすもの: R096 gold-external-sources.json.md → dev-sources.json.md
|
|
240
|
+
* (同じ用語の正真正銘の改名。差分は id: の1行だけ。100% だと削除+追加に割れ、
|
|
241
|
+
* 削除で打ち切られて**改名前の起票が丸ごと消える**——直そうとしている症状そのもの)
|
|
242
|
+
*
|
|
243
|
+
* **50% まで下げても増える改名は1件も無い**(=この案件に「似た別レコードが繋がる」形は無い)。
|
|
244
|
+
* それでも 90% に留めるのは、**誤検出の被害が重い**から——別々の記録が繋がると
|
|
245
|
+
* 「人が消した本文が別の記録のページに出る」(0.12.15 の実装レビューで直した失敗)が再発する。
|
|
246
|
+
* Gold のレコードで別々の記録が9割一致することは事実上ないので、ここが安全と実用の折り合い。
|
|
247
|
+
*
|
|
248
|
+
* 🔴 **閾値を動かすときは readDiffs の --diff-filter と mCount の数え方も一緒に見ること。**
|
|
249
|
+
* 100% 未満の改名は**中身も変わっている**ので、差分を取って「書き換え」として出す必要がある。
|
|
250
|
+
* 揃えないと、人が「改名しながら中身も直した」変更が**無音で消える**。
|
|
251
|
+
*/
|
|
252
|
+
const RENAME_OPT = "--find-renames=90%";
|
|
253
|
+
/** git log を1回。失敗(git 無し・リポジトリでない・出力が大きすぎる)は空文字にする */
|
|
254
|
+
function gitLog(cwd, args) {
|
|
255
|
+
try {
|
|
256
|
+
// 🔴 **出力の形は、読む人の git 設定に左右させない。** ここで組み立てているのは
|
|
257
|
+
// 機械が読む出力なので、人の ~/.gitconfig で形が変わる余地を全部潰す:
|
|
258
|
+
//
|
|
259
|
+
// core.quotepath=false … 既定の git は ASCII 以外を含むパスを
|
|
260
|
+
// `"Cortex/Glossary/records/\344\273\256…"` と引用符でくるむ。用語集・メンバーの
|
|
261
|
+
// レコードは**日本語のファイル名**なので、付け忘れると画面側の照合キー
|
|
262
|
+
// (repoPathOf の生パス)と一致せず、履歴が**丸ごと出なくなる**(実装中に踏んだ)。
|
|
263
|
+
// なお、これが消すのは**非ASCIIの引用だけ**。ファイル名に `"` `\` 制御文字が
|
|
264
|
+
// 入ると git は常に引用符でくるむ——そのレコードは RECORD_PATH に一致せず、
|
|
265
|
+
// 履歴が出ないだけになる(事故ではないが、出ない理由はこれ)
|
|
266
|
+
// diff.mnemonicPrefix / diff.noprefix … `+++ b/<path>` の接頭辞が `w/` になったり
|
|
267
|
+
// 消えたりする。パスの取り出しが狂うので、下で接頭辞を明示して固定する
|
|
268
|
+
// log.showSignature=false … 署名の検証結果が本文に混ざる
|
|
269
|
+
// color.ui=false / --no-color … 色の制御文字が混ざる
|
|
270
|
+
return (0, node_child_process_1.execFileSync)("git", [
|
|
271
|
+
"-c", "core.quotepath=false",
|
|
272
|
+
"-c", "log.showSignature=false",
|
|
273
|
+
"-c", "color.ui=false",
|
|
274
|
+
// 改名検出の探索上限(既定1000)。**読む人の設定で改名が黙って切れる**のを防ぐ。
|
|
275
|
+
// pathspec でレコードだけに絞ってあるので、実際に比べる数は多くても数百
|
|
276
|
+
"-c", "diff.renameLimit=100000",
|
|
277
|
+
...args,
|
|
278
|
+
], {
|
|
279
|
+
cwd,
|
|
280
|
+
encoding: "utf8",
|
|
281
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
282
|
+
// 既定の 1MB では足りない。差分を丸ごと受け取るので広めに取り、
|
|
283
|
+
// 溢れたら例外 → 上位で空扱い(履歴が出ないだけでビルドは通る)
|
|
284
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
// **握り潰すが、異常なものは無言にしない。** maxBuffer 超過や git の内部エラーまで
|
|
289
|
+
// 「履歴の節が消える」に潰れると、**ある日消えても誰も気づけない**。
|
|
290
|
+
//
|
|
291
|
+
// ただし「**git が無い**(ENOENT)」「**git リポジトリでない**(git の致命エラー = 128)」は
|
|
292
|
+
// **既定の状態**——手元のビルドやテストで普通に起きるので、ここで警告するとログが
|
|
293
|
+
// 常に注意で埋まる(gold-proposals / weekly-curation の「無いことは警告しない」と同じ規律)。
|
|
294
|
+
// 戻り値の契約(空文字 = 履歴なしで正常継続)はどちらでも変えない
|
|
295
|
+
const err = e;
|
|
296
|
+
if (err.code !== "ENOENT" && err.status !== 128) {
|
|
297
|
+
console.warn(`AIの変更履歴を git から取れませんでした(履歴は出さずに続けます): ${String(e)}`);
|
|
298
|
+
}
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** パス1: コミットの素性と、そのコミットが触ったレコードのパス(新しい順) */
|
|
303
|
+
function readCommits(cwd) {
|
|
304
|
+
const out = gitLog(cwd, [
|
|
305
|
+
"log",
|
|
306
|
+
`-n${AI_HISTORY_MAX_COMMITS}`,
|
|
307
|
+
// 🔴 **改名を検出する(0.12.16)。** --no-renames だと `Cortex/用語集/` → `Cortex/Glossary/`
|
|
308
|
+
// のようなディレクトリの引っ越しが**削除+追加**に割れ、(1) 新パスに「AIがこの記録を
|
|
309
|
+
// 作成しました」が立ち (2) 旧パスは削除扱いで**移動より前の履歴が丸ごと消える**。
|
|
310
|
+
// 追跡は collectAiChangeHistory 側で1パスで行う(--follow は単一パス専用で、
|
|
311
|
+
// レコードごとに git を呼ぶことになり「ビルド1回につき2回」を壊す)。
|
|
312
|
+
// **log.follow=true を設定している人がいる**ので --no-follow も明示する
|
|
313
|
+
RENAME_OPT,
|
|
314
|
+
"--no-follow",
|
|
315
|
+
"--no-color",
|
|
316
|
+
// パス2と揃える(--name-status はテキスト差分を作らないので発火しないが、
|
|
317
|
+
// 「読む人の git 設定に左右させない」の徹底を非対称にしない)
|
|
318
|
+
"--no-ext-diff",
|
|
319
|
+
"--name-status",
|
|
320
|
+
`--format=%x00%H%x1f%aI%x1f%an%x1f%ae%x1f%s`,
|
|
321
|
+
"--",
|
|
322
|
+
RECORD_PATHSPEC,
|
|
323
|
+
]);
|
|
324
|
+
const commits = [];
|
|
325
|
+
let cur = null;
|
|
326
|
+
for (const line of out.split("\n")) {
|
|
327
|
+
if (line.startsWith(NUL)) {
|
|
328
|
+
const parts = line.slice(1).split(US);
|
|
329
|
+
// 件名に区切り文字が混ざっても壊れないよう、5つ目以降は件名として繋ぎ直す
|
|
330
|
+
cur = {
|
|
331
|
+
sha: parts[0] || "",
|
|
332
|
+
at: parts[1] || "",
|
|
333
|
+
name: parts[2] || "",
|
|
334
|
+
email: parts[3] || "",
|
|
335
|
+
subject: parts.slice(4).join(US),
|
|
336
|
+
files: [],
|
|
337
|
+
};
|
|
338
|
+
if (cur.sha)
|
|
339
|
+
commits.push(cur);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (!cur)
|
|
343
|
+
continue;
|
|
344
|
+
const m = /^([A-Z])\d*\t(.+)$/.exec(line);
|
|
345
|
+
if (!m)
|
|
346
|
+
continue;
|
|
347
|
+
if (m[1] === "R") {
|
|
348
|
+
// 改名は「旧パス<TAB>新パス」の2列。行頭の `R100` の数字は一致度
|
|
349
|
+
const i = m[2].indexOf("\t");
|
|
350
|
+
if (i === -1)
|
|
351
|
+
continue;
|
|
352
|
+
const score = parseInt(/^R(\d+)/.exec(line)?.[1] || "", 10);
|
|
353
|
+
cur.files.push({
|
|
354
|
+
status: "R",
|
|
355
|
+
path: m[2].slice(i + 1),
|
|
356
|
+
from: m[2].slice(0, i),
|
|
357
|
+
score: Number.isFinite(score) ? score : undefined,
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// 🔴 **複製(C)は改名と同じ扱いにしない。** 複製は**元ファイルが残る**ので、
|
|
362
|
+
// 対応表を張ると元レコードの履歴が複製先へ移り、**元のページが空になる**。
|
|
363
|
+
// いまは `--find-copies` を渡していないので届かない(`diff.renames=copies` が
|
|
364
|
+
// 設定されていても `--find-renames` が優先されることを実測で確認済み)。
|
|
365
|
+
// あとから複製検出を足す人は、ここで扱いを決めること
|
|
366
|
+
if (m[1] === "C")
|
|
367
|
+
continue;
|
|
368
|
+
cur.files.push({ status: m[1], path: m[2] });
|
|
369
|
+
}
|
|
370
|
+
return commits;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* パス2: 変更(M)の差分だけ。鍵は `<sha>` + NUL + `<path>`。
|
|
374
|
+
*
|
|
375
|
+
* 🔴 **遡る件数はパス1が決める(`changeCount` = パス1の窓に入った「変更または改名」のコミット数)。**
|
|
376
|
+
* `--diff-filter` は表示だけでなく**コミットの選択**にも効くので、ここで素の
|
|
377
|
+
* `-n400` を渡すと「変更のコミット 400 件」を探して**履歴の底まで歩き、その全部の
|
|
378
|
+
* パッチを生成する**——レコードの変更が少ないリポジトリほど無駄が大きく、
|
|
379
|
+
* 作った差分の大半はパス1の窓の外で一度も引かれない。
|
|
380
|
+
*
|
|
381
|
+
* パス1の窓は「レコードを触ったコミットの新しい 400 件」という連続した区間なので、
|
|
382
|
+
* その中の該当コミットは**全体で最も新しい changeCount 件と一致する**。
|
|
383
|
+
* だから `-n changeCount` で必要十分(多くも少なくもならない)。
|
|
384
|
+
* **数え方は --diff-filter と必ず揃えること**(片方だけ変えると取りこぼす)。
|
|
385
|
+
*/
|
|
386
|
+
function readDiffs(cwd, changeCount) {
|
|
387
|
+
const empty = { diffs: new Map(), modeOnly: new Set() };
|
|
388
|
+
if (changeCount <= 0)
|
|
389
|
+
return empty;
|
|
390
|
+
const out = gitLog(cwd, [
|
|
391
|
+
"log",
|
|
392
|
+
`-n${changeCount}`,
|
|
393
|
+
// パス1と**同じ改名の設定**にする(違えるとコミットの選択がずれ、mCount の前提が崩れる)
|
|
394
|
+
RENAME_OPT,
|
|
395
|
+
"--no-follow",
|
|
396
|
+
"--no-color",
|
|
397
|
+
// diff.external / GIT_EXTERNAL_DIFF が設定されていると差分が丸ごと別物になる
|
|
398
|
+
"--no-ext-diff",
|
|
399
|
+
// 接頭辞を明示して固定する(diff.mnemonicPrefix / diff.noprefix の影響を消す)
|
|
400
|
+
"--src-prefix=a/",
|
|
401
|
+
"--dst-prefix=b/",
|
|
402
|
+
"-p",
|
|
403
|
+
// 文脈行は要らない。**変わった行だけ**を出す(画面のラベルも「消えた行/増えた行」)
|
|
404
|
+
"-U0",
|
|
405
|
+
// **M だけでなく R も取る。** 100% 未満の改名は中身も変わっているので、その差分が要る
|
|
406
|
+
//(純粋な改名は R でもパッチを持たないので、ここに入っても何も増えない)
|
|
407
|
+
"--diff-filter=MR",
|
|
408
|
+
`--format=%x00%H`,
|
|
409
|
+
"--",
|
|
410
|
+
RECORD_PATHSPEC,
|
|
411
|
+
]);
|
|
412
|
+
const diffs = new Map();
|
|
413
|
+
/**
|
|
414
|
+
* 🔴 **「中身は本当に変わっていない」と言い切れるものだけを集める。**
|
|
415
|
+
* テキスト差分が出ない節は3種類あり、意味がまったく違う:
|
|
416
|
+
*
|
|
417
|
+
* old mode / new mode … **権限だけの変更**。中身は変わっていない(ここに入れる)
|
|
418
|
+
* Binary files … differ … **中身は変わったが、差分を出せない**(.gitattributes の -diff)
|
|
419
|
+
* similarity index 100% … 純粋な改名(R のスコアで分かるので、ここでは扱わない)
|
|
420
|
+
*
|
|
421
|
+
* これを区別せずに「差分が空なら落とす」とすると、`*.md` を -diff にしている案件で
|
|
422
|
+
* **本物の書き換えが丸ごと消える**(レビューで指摘された潜在の欠陥)。
|
|
423
|
+
*/
|
|
424
|
+
const modeOnly = new Set();
|
|
425
|
+
let sha = "";
|
|
426
|
+
let cur = null;
|
|
427
|
+
/** いまのファイル節の新パス(`diff --git a/X b/Y` の Y)。`+++` が来たら確定して消す */
|
|
428
|
+
let headerPath = "";
|
|
429
|
+
let sawModeLine = false;
|
|
430
|
+
/** ファイル節の終わりで、テキスト差分が出なかった理由を確定する */
|
|
431
|
+
const closeSection = () => {
|
|
432
|
+
if (headerPath && sawModeLine && sha)
|
|
433
|
+
modeOnly.add(sha + NUL + headerPath);
|
|
434
|
+
headerPath = "";
|
|
435
|
+
sawModeLine = false;
|
|
436
|
+
};
|
|
437
|
+
// 🔴 **ヘッダの見分けは「位置」で決める。行の見た目で決めない。**
|
|
438
|
+
// 差分の中身は「符号1文字 + 元の行」なので、レコードに `++ …` という行があると
|
|
439
|
+
// `+++ …` に、`-- …` があると `--- …` になる——**ファイルのヘッダと区別が付かない**。
|
|
440
|
+
// 見た目で判定すると、その行を落とすだけでなく**以降の差分が別のパスの下に入る**。
|
|
441
|
+
// 区間で見る: `diff --git` から最初の `@@` までがヘッダ、そこから次の
|
|
442
|
+
// `diff --git` までが中身。
|
|
443
|
+
let inHeader = false;
|
|
444
|
+
for (const line of out.split("\n")) {
|
|
445
|
+
if (line.startsWith(NUL)) {
|
|
446
|
+
closeSection();
|
|
447
|
+
sha = line.slice(1).trim();
|
|
448
|
+
cur = null;
|
|
449
|
+
inHeader = false;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (line.startsWith(DIFF_HEADER)) {
|
|
453
|
+
closeSection();
|
|
454
|
+
cur = null;
|
|
455
|
+
inHeader = true;
|
|
456
|
+
// 権限だけの変更・binary は `+++` を出さないので、ここでしかパスを取れない。
|
|
457
|
+
// 接頭辞は --src-prefix / --dst-prefix で固定してあるので、` b/` の**最後の**
|
|
458
|
+
// 出現で割ればよい(レコード名に空白は入りうるが、` b/` は入らない)
|
|
459
|
+
const rest = line.slice(DIFF_HEADER.length);
|
|
460
|
+
const at = rest.lastIndexOf(" b/");
|
|
461
|
+
headerPath = at === -1 ? "" : rest.slice(at + 3);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (inHeader) {
|
|
465
|
+
// パスは `+++ b/<path>` から取る(上の行は空白を含むパスで割れない)
|
|
466
|
+
if (line.startsWith("+++ ")) {
|
|
467
|
+
const p = line.slice(4).trim();
|
|
468
|
+
headerPath = "";
|
|
469
|
+
sawModeLine = false;
|
|
470
|
+
if (p === "/dev/null" || !sha) {
|
|
471
|
+
cur = null;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const path = p.startsWith("b/") ? p.slice(2) : p;
|
|
475
|
+
cur = { before: [], after: [] };
|
|
476
|
+
diffs.set(sha + NUL + path, cur);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (line.startsWith("old mode ") || line.startsWith("new mode "))
|
|
480
|
+
sawModeLine = true;
|
|
481
|
+
// 最初のハンク見出しでヘッダは終わり(index / old mode / `---` はここで捨てる)
|
|
482
|
+
if (line.startsWith("@@"))
|
|
483
|
+
inHeader = false;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (!cur)
|
|
487
|
+
continue;
|
|
488
|
+
// 2つ目以降のハンク見出し。中身の行には必ず符号が付くので、素の `@@` は見出しだけ
|
|
489
|
+
if (line.startsWith("@@"))
|
|
490
|
+
continue;
|
|
491
|
+
// `` は符号が付かないので、下の2つに落ちず自然に無視される
|
|
492
|
+
if (line.startsWith("-"))
|
|
493
|
+
cur.before.push(line.slice(1));
|
|
494
|
+
else if (line.startsWith("+"))
|
|
495
|
+
cur.after.push(line.slice(1));
|
|
496
|
+
}
|
|
497
|
+
closeSection();
|
|
498
|
+
return { diffs, modeOnly };
|
|
499
|
+
}
|
|
500
|
+
/** 長すぎる差分は丸める(site の JSON に丸ごと載るので上限が要る) */
|
|
501
|
+
function clampDiff(text) {
|
|
502
|
+
if (text.length <= AI_HISTORY_MAX_DIFF_CHARS)
|
|
503
|
+
return { text, truncated: false };
|
|
504
|
+
let end = AI_HISTORY_MAX_DIFF_CHARS;
|
|
505
|
+
// 上位サロゲートで切ると、絵文字が半分だけ残って末尾が置換文字になる。1つ戻す
|
|
506
|
+
const code = text.charCodeAt(end - 1);
|
|
507
|
+
if (code >= 0xd800 && code <= 0xdbff)
|
|
508
|
+
end -= 1;
|
|
509
|
+
return { text: text.slice(0, end), truncated: true };
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* レコードのリポジトリ相対パス → AIによる変更(新しい順・最大 AI_HISTORY_MAX_ENTRIES 件)。
|
|
513
|
+
*
|
|
514
|
+
* 画面は既存の `repoPathOf(rec)`(editUrl から剥がしたパス)でここを引く。
|
|
515
|
+
* **いまリポジトリに無いレコード(最新の状態が削除)は地図に入れない**——
|
|
516
|
+
* もう開けないページの履歴を配っても読む人がいない。
|
|
517
|
+
*/
|
|
518
|
+
function collectAiChangeHistory(cwd) {
|
|
519
|
+
const commits = readCommits(cwd);
|
|
520
|
+
if (!commits.length)
|
|
521
|
+
return {};
|
|
522
|
+
// パス2に渡す件数を、パス1の窓に入った「変更または改名」のコミット数から数える。
|
|
523
|
+
// **readDiffs の --diff-filter と同じ集合を数えること**(片方だけ変えると差分を取りこぼす)
|
|
524
|
+
let changeCount = 0;
|
|
525
|
+
for (const c of commits) {
|
|
526
|
+
if (c.files.some((f) => (f.status === "M" || f.status === "R") && PATHSPEC_MATCH.test(f.path)))
|
|
527
|
+
changeCount++;
|
|
528
|
+
}
|
|
529
|
+
const { diffs, modeOnly } = readDiffs(cwd, changeCount);
|
|
530
|
+
const history = {};
|
|
531
|
+
/**
|
|
532
|
+
* 🔴 **削除でそのパスの収集を打ち切る。鍵は「そのコミット時点の生パス」。**
|
|
533
|
+
*
|
|
534
|
+
* 新しい順に走っているので、削除より古い世代は**人が消したはずの内容**——同じパスに
|
|
535
|
+
* 記録が作り直されると、消したはずの本文が新しい記録のページに出てしまう。
|
|
536
|
+
*
|
|
537
|
+
* **正本パスで引いてはいけない。** 改名追跡を入れると別々の記録が同じ正本パスに写像されるので、
|
|
538
|
+
* 「削除で空いたパスに、あとから別の記録が改名で入ってくる」形で、
|
|
539
|
+
* **移動してきた生きている記録の履歴まで丸ごと消える**(実装レビューで捕まえた)。
|
|
540
|
+
* 生パスで持ち、改名を見たときに新→旧へ伝播させると、2つの形が順序で綺麗に分かれる:
|
|
541
|
+
*
|
|
542
|
+
* α 改名してから削除された(その記録は死んでいる)… `D 新` を R より先に見るので、
|
|
543
|
+
* R のとき gone に 新 が居る → 旧へ伝播して古い履歴も捨てる
|
|
544
|
+
* β 削除で空いたパスに、あとから別の記録が入った … `R 旧→新` を D より先に見るので、
|
|
545
|
+
* 伝播せず、あとから来る `D 新` は生パス 新 にだけ効く。移動してきた記録は
|
|
546
|
+
* 生パス 旧 で照合されるので生き残る
|
|
547
|
+
*/
|
|
548
|
+
const gone = new Set();
|
|
549
|
+
/**
|
|
550
|
+
* 🔴 **改名の追跡。「そのコミット時点のパス」→「いまのパス(正本)」。**
|
|
551
|
+
*
|
|
552
|
+
* 新しい順に走りながら育てる。改名 R(旧 → 新) を見たら、その時点より**古い**コミットで
|
|
553
|
+
* 旧パスに出会ったものは、いまの記録の履歴として積む。これで `--follow` と同じことを
|
|
554
|
+
* **git を1回も余分に呼ばずに**やる。改名が連鎖していても(A→B→C)順に付け替わって繋がる。
|
|
555
|
+
*/
|
|
556
|
+
const renamedTo = new Map();
|
|
557
|
+
/** そのコミット時点のパスから、いまのパス(正本)を引く */
|
|
558
|
+
const canonicalOf = (p) => renamedTo.get(p) || p;
|
|
559
|
+
/** レコードとして扱うパスか(区画の規約・雛形はレコードではない) */
|
|
560
|
+
const isRecord = (p) => {
|
|
561
|
+
if (!RECORD_PATH.test(p))
|
|
562
|
+
return false;
|
|
563
|
+
const base = p.slice(p.lastIndexOf("/") + 1).toLowerCase();
|
|
564
|
+
return base !== "readme.md" && base !== "template.md";
|
|
565
|
+
};
|
|
566
|
+
for (const c of commits) {
|
|
567
|
+
const kind = classifyCommit(c);
|
|
568
|
+
/**
|
|
569
|
+
* このコミットの改名。**対応表への反映は、通常のエントリを処理し終えてから行う。**
|
|
570
|
+
* 途中で書き換えると、同じコミットの他のエントリの正本が git の出力順に左右される。
|
|
571
|
+
* `-M` だけなら実際には起こらない(R の元/先は同じコミットの他のエントリに現れない)が、
|
|
572
|
+
* あとから `-C` を足した瞬間に静かに壊れるので、順序を不変条件として固定しておく。
|
|
573
|
+
*/
|
|
574
|
+
const renames = [];
|
|
575
|
+
for (const f of c.files) {
|
|
576
|
+
if (f.status === "R" && f.from) {
|
|
577
|
+
const canon = canonicalOf(f.path);
|
|
578
|
+
renames.push({ from: f.from, to: f.path, canon });
|
|
579
|
+
// **中身も変わった改名(100% 未満)だけを「書き換え」として出す。**
|
|
580
|
+
// 一致度 100 は**中身が1文字も変わっていない移動**——記録の内容の変化ではないので
|
|
581
|
+
// 履歴の点にしない。**スコアで判定する**(差分の有無で判定すると、差分が
|
|
582
|
+
// 取れなかっただけの変更と区別が付かない)
|
|
583
|
+
if (kind === "ai" && isRecord(canon) && !gone.has(f.path) && f.score !== 100) {
|
|
584
|
+
pushEntry(canon, c, f.path, "edit");
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const canon = canonicalOf(f.path);
|
|
589
|
+
if (!isRecord(canon))
|
|
590
|
+
continue;
|
|
591
|
+
if (gone.has(f.path))
|
|
592
|
+
continue;
|
|
593
|
+
if (f.status === "D") {
|
|
594
|
+
gone.add(f.path);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
// 履歴に出すのは AI がやったことだけ(人の操作・データ構造の引っ越しは出さない)
|
|
598
|
+
if (kind !== "ai")
|
|
599
|
+
continue;
|
|
600
|
+
// A(作成)と M(変更)だけ。T(ファイル↔シンボリックリンクの置き換え)・
|
|
601
|
+
// C(複製・そもそも検出していない)等は「何が変わったか」を言えないので出さない
|
|
602
|
+
if (f.status !== "A" && f.status !== "M")
|
|
603
|
+
continue;
|
|
604
|
+
pushEntry(canon, c, f.path, f.status === "A" ? "add" : "edit");
|
|
605
|
+
}
|
|
606
|
+
for (const r of renames) {
|
|
607
|
+
// α: 改名先が既に死んでいるなら、改名元も死んでいる(古い履歴を捨てる)
|
|
608
|
+
if (gone.has(r.to))
|
|
609
|
+
gone.add(r.from);
|
|
610
|
+
// 改名先の名前は、これより古いコミットでは**別のもの**を指す。対応表から外す。
|
|
611
|
+
// ⚠️ **この1行はテストで守れていない**(外しても全テストが緑のまま)。外しても症状が
|
|
612
|
+
// 出ないのは、名前が再利用される形では手前の `gone` が先に効いてしまうため。
|
|
613
|
+
// 消さないでほしい: 連鎖する改名で、古い側の同名ファイルが新しい記録に
|
|
614
|
+
// 紐づく余地を残さないための一行
|
|
615
|
+
renamedTo.delete(r.to);
|
|
616
|
+
// 🔴 **写像を張れない改名は、旧パスをそこで打ち切る。**
|
|
617
|
+
// レコードの形をしていないところへ(`records/2026/A.md` のようなサブフォルダ・
|
|
618
|
+
// `README.md`)出ていった、あるいはそこから来た改名で、旧パスを生かしたままにすると、
|
|
619
|
+
// **旧パスは「もう無いのに生きている」**状態になる。そこに新しい記録が作り直されると、
|
|
620
|
+
// **前の記録の作成・本文が新しい記録のページに出る**——0.12.15 で直した
|
|
621
|
+
// 「人が消した本文が別の記録に出る」と同じ型(レビューで実測・再現された)
|
|
622
|
+
if (isRecord(r.canon) && isRecord(r.from))
|
|
623
|
+
renamedTo.set(r.from, r.canon);
|
|
624
|
+
else
|
|
625
|
+
gone.add(r.from);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** 1件を積む。**中身が変わっていない「書き換え」は積まない** */
|
|
629
|
+
function pushEntry(canon, c, rawPath, kind) {
|
|
630
|
+
const list = history[canon] || (history[canon] = []);
|
|
631
|
+
if (list.length >= exports.AI_HISTORY_MAX_ENTRIES)
|
|
632
|
+
return;
|
|
633
|
+
let before = "";
|
|
634
|
+
let after = "";
|
|
635
|
+
let truncated = false;
|
|
636
|
+
const key = c.sha + NUL + rawPath;
|
|
637
|
+
// **「追加(A)の差分を載せない」の門は readDiffs の --diff-filter ただ1つ。**
|
|
638
|
+
// ここでも status を見て二重に守ると、どちらを外しても症状が出ず、
|
|
639
|
+
// **テストで守れない不変条件**になる(実際に変異検証で素通りした)。門は1箇所に置く
|
|
640
|
+
const d = diffs.get(key);
|
|
641
|
+
if (d) {
|
|
642
|
+
const b = clampDiff(d.before.join("\n"));
|
|
643
|
+
const a = clampDiff(d.after.join("\n"));
|
|
644
|
+
before = b.text;
|
|
645
|
+
after = a.text;
|
|
646
|
+
truncated = b.truncated || a.truncated;
|
|
647
|
+
}
|
|
648
|
+
if (kind === "edit") {
|
|
649
|
+
// 🔴 **構造の規則A: 中身が変わっていないと「言い切れる」変更だけを落とす。**
|
|
650
|
+
//
|
|
651
|
+
// ⚠️ **「差分が空」と「差分が取れなかった」を同じにしてはいけない**(レビュー指摘)。
|
|
652
|
+
// テキスト差分が出ない理由は3つあり、意味がまったく違う:
|
|
653
|
+
//
|
|
654
|
+
// ① 権限だけの変更 …… 中身は変わっていない(git が old mode/new mode で明言)→ 落とす
|
|
655
|
+
// ② 純粋な改名 …………… 中身は変わっていない(R のスコア100で判定済み)→ ここへ来ない
|
|
656
|
+
// ③ 取れなかった ……… binary 扱い(.gitattributes の -diff)・パス2の窓の外・
|
|
657
|
+
// パス2そのものの失敗。**中身は変わっているかもしれない**
|
|
658
|
+
//
|
|
659
|
+
// ③を落とすと、`*.md` を -diff にしている案件で**本物の書き換えが丸ごと消え**、
|
|
660
|
+
// パス2が失敗した日には**全案件で履歴が無言で全滅**する。だから③は落とさず、
|
|
661
|
+
// 差分の代わりに画面へ「出せませんでした」と断らせる
|
|
662
|
+
if (!before && !after && modeOnly.has(key))
|
|
663
|
+
return;
|
|
664
|
+
// 🔴 **構造の規則B: フロントマターの項目名だけの付け替えは出さない。**
|
|
665
|
+
// データ構造の引っ越しであって内容の変化ではない(件名の規則の裏取り)。
|
|
666
|
+
// ⚠️ ただし裏取りできるのは**実データで見た2つの形のうち1つだけ**
|
|
667
|
+
//(`summary:`→`description:` の169件。`status:` の後付け300件は件名の規則だけが頼り)
|
|
668
|
+
if (isFrontmatterKeyRename(before, after))
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const entry = {
|
|
672
|
+
commit: c.sha,
|
|
673
|
+
at: c.at,
|
|
674
|
+
subject: c.subject,
|
|
675
|
+
kind,
|
|
676
|
+
before,
|
|
677
|
+
after,
|
|
678
|
+
};
|
|
679
|
+
if (truncated)
|
|
680
|
+
entry.truncated = true;
|
|
681
|
+
list.push(entry);
|
|
682
|
+
}
|
|
683
|
+
// 空の器は返さない(「知らないことはキーが無いことで表す」)
|
|
684
|
+
for (const p of Object.keys(history)) {
|
|
685
|
+
if (!history[p].length)
|
|
686
|
+
delete history[p];
|
|
687
|
+
}
|
|
688
|
+
return history;
|
|
689
|
+
}
|
package/package.json
CHANGED