@takagaki/cortex-decisions-viewer 0.12.119 → 0.12.121
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 +43 -7
- package/dist/change-log.js +187 -0
- package/dist/cli.js +1 -1
- package/dist/hands.js +52 -0
- package/dist/ledger-dir.js +1 -1
- package/dist/render.js +180 -150
- package/dist/repo.js +11 -508
- package/dist/story.js +66 -3
- package/dist/supersedes.js +8 -21
- package/package.json +2 -1
package/dist/repo.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AI_HISTORY_MAX_ENTRIES = void 0;
|
|
4
3
|
exports.resolveRepo = resolveRepo;
|
|
5
4
|
exports.latestGeneratedPaths = latestGeneratedPaths;
|
|
6
5
|
exports.normalizeToHttps = normalizeToHttps;
|
|
7
6
|
exports.classifyCommit = classifyCommit;
|
|
8
|
-
exports.isFrontmatterKeyRename = isFrontmatterKeyRename;
|
|
9
|
-
exports.collectAiChangeHistory = collectAiChangeHistory;
|
|
10
7
|
exports.lastSubstantiveUpdate = lastSubstantiveUpdate;
|
|
11
8
|
const node_child_process_1 = require("node:child_process");
|
|
12
9
|
/**
|
|
@@ -120,47 +117,14 @@ function normalizeToHttps(url) {
|
|
|
120
117
|
}
|
|
121
118
|
return null;
|
|
122
119
|
}
|
|
123
|
-
// ----
|
|
120
|
+
// ---- git の履歴の読み口(Home の最終更新日が使う)------------------------------
|
|
124
121
|
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// 翌週の実行でレポートが差し替わると前の履歴が画面から消えた**(オーナーが実際に踏んだ)。
|
|
128
|
-
// 「黙って書き換えられた記録を作らない」という節の目的に対して、1週間で消える履歴は
|
|
129
|
-
// 用を成さない。git のコミットは消えないので、そちらを正本にする。
|
|
130
|
-
//
|
|
131
|
-
// **git の呼び出しはレコード数に依存させない。ビルド1回につき2回だけ呼ぶ。**
|
|
132
|
-
// レコードごとに git log を回すと、案件が育つほどビルドが線形に遅くなる。
|
|
133
|
-
//
|
|
134
|
-
// パス1: --name-status … コミットの素性(誰が・いつ・何を言って)と触ったパスだけ。出力は軽い
|
|
135
|
-
// パス2: -p -U0 --diff-filter=M … **変更(M)の差分だけ**。追加(A)の差分は取らない
|
|
136
|
-
// ——追加時の「変更後」はレコード本文そのもので、
|
|
137
|
-
// 同じページに全文が既に出ている(site の JSON が二重になる)
|
|
138
|
-
//
|
|
139
|
-
// 例外は全部握って空を返す(latestGeneratedPaths と同じ規律)。浅い clone・git 無し・
|
|
140
|
-
// メッセージ規約の変更 → 「履歴の節が出ない」だけで、他の表示は壊れない。
|
|
141
|
-
/** ファイル節の始まりの印 */
|
|
142
|
-
const DIFF_HEADER = "diff --" + "git ";
|
|
122
|
+
// 記録ページの「更新履歴」は engine の帳簿を読む(change-log.ts・#1094 便 35b-1 U2)。ここに残るのは
|
|
123
|
+
// Home の「実質的な最終更新日」(lastSubstantiveUpdate)だけ。
|
|
143
124
|
/** 行頭に置いてコミットの境目を見分ける印。差分もパスも NUL では始まらない */
|
|
144
125
|
const NUL = "\u0000";
|
|
145
126
|
/** コミット1行の中の区切り(Unit Separator)。コミットメッセージに現れない制御文字を選ぶ */
|
|
146
127
|
const US = "\u001f";
|
|
147
|
-
/** 遡るコミット数の上限。深追いしてもビルドが遅くなるだけで、画面に出るのはレコードごとに数件 */
|
|
148
|
-
const AI_HISTORY_MAX_COMMITS = 400;
|
|
149
|
-
/** レコードごとに画面へ渡す件数(分類B・2026-08-29)。折りたたみの中でも1件が差分を持つので絞る */
|
|
150
|
-
exports.AI_HISTORY_MAX_ENTRIES = 5;
|
|
151
|
-
/** 変更前・変更後それぞれの文字数の上限。超えたら丸めて truncated を立てる */
|
|
152
|
-
const AI_HISTORY_MAX_DIFF_CHARS = 2000;
|
|
153
|
-
/** レコードのパスの形。README・雛形はレコードではない */
|
|
154
|
-
const RECORD_PATH = /^Cortex\/[^/]+\/records\/[^/]+\.md$/;
|
|
155
|
-
/**
|
|
156
|
-
* 下の pathspec が git 側で拾う範囲。**`RECORD_PATH` より広い。**
|
|
157
|
-
* git の pathspec の `*` は `/` をまたぐので、`Cortex/A/records/sub/x.md` のような
|
|
158
|
-
* 入れ子も git は選ぶ。**パス2へ渡す件数はこちらで数える**——狭いほうで数えると
|
|
159
|
-
* 窓が浅くなり、**窓の端の本物の変更が黙って消える**(レビューで指摘された潜在の欠陥)。
|
|
160
|
-
*/
|
|
161
|
-
const PATHSPEC_MATCH = /^Cortex\/.*\/records\/.*\.md$/;
|
|
162
|
-
/** 探索範囲(git の pathspec)。README や Context 配下の差分は最初から出力に入れない */
|
|
163
|
-
const RECORD_PATHSPEC = "Cortex/*/records/*.md";
|
|
164
128
|
function classifyCommit(id) {
|
|
165
129
|
const subject = id.subject || "";
|
|
166
130
|
// 1. 人の操作の代理(infra/lambda/intake/logic.mjs の promoteCommitMessage / deleteCommitMessage /
|
|
@@ -207,80 +171,13 @@ function classifyCommit(id) {
|
|
|
207
171
|
function looksLikeBot(id) {
|
|
208
172
|
return /\[bot\]|github-actions/i.test(`${id.name || ""} ${id.email || ""}`);
|
|
209
173
|
}
|
|
210
|
-
/**
|
|
211
|
-
* 🔴 **フロントマターの「項目名だけ」が変わった差分か。**(purpose に依存しない構造の規則)
|
|
212
|
-
*
|
|
213
|
-
* 消えた行と増えた行が**すべて `key: value` の形**で、**値の集合が変わっていない**なら、
|
|
214
|
-
* それは項目名の付け替え=データ構造の引っ越しであって、記録の内容の変化ではない。
|
|
215
|
-
* 実データの `summary: "X"` → `description: "X"` はこれで落ちる。件名の書き方が
|
|
216
|
-
* 変わっても落ちるので、上の件名の規則の**裏取り**になる。
|
|
217
|
-
*
|
|
218
|
-
* 一方 `definition: 旧` → `definition: 新`(本物の精製)は**値が変わる**ので残る。
|
|
219
|
-
*
|
|
220
|
-
* **片側だけの変更(追加だけ・削除だけ)は対象にしない。** 「フロントマターに項目が
|
|
221
|
-
* 1つ増えた」を機械的に落とすと、AIが definition や aliases を足した**本物の精製まで消える**。
|
|
222
|
-
* 実データの「`status:` 行の後付け」はこの規則では落とさず、件名の規則に任せる——
|
|
223
|
-
* **構造の規則は「内容が変わっていないと証明できるもの」だけに限る。**
|
|
224
|
-
*/
|
|
225
|
-
// ⚠️ 名前は「フロントマターの」だが、**見ているのは差分の行だけで、その行が
|
|
226
|
-
// frontmatter の内側かどうかは判定していない**(差分だけでは境界が分からない)。
|
|
227
|
-
// 本文に `key: value` の形の行があり、値を保ったままキーだけ変える変更は落ちる。
|
|
228
|
-
// 実データではその形は1件も無いが、そういう規則だと承知して使うこと。
|
|
229
|
-
function isFrontmatterKeyRename(before, after) {
|
|
230
|
-
const lines = (t) => t.split("\n").filter((l) => l.trim() !== "");
|
|
231
|
-
const bs = lines(before);
|
|
232
|
-
const as = lines(after);
|
|
233
|
-
if (!bs.length || !as.length)
|
|
234
|
-
return false;
|
|
235
|
-
const KEY_VALUE = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.*)$/;
|
|
236
|
-
const parse = (arr) => {
|
|
237
|
-
const keys = [];
|
|
238
|
-
const values = [];
|
|
239
|
-
for (const l of arr) {
|
|
240
|
-
const m = KEY_VALUE.exec(l);
|
|
241
|
-
if (!m)
|
|
242
|
-
return null;
|
|
243
|
-
keys.push(m[1]);
|
|
244
|
-
values.push(m[2].trim());
|
|
245
|
-
}
|
|
246
|
-
return { keys, values };
|
|
247
|
-
};
|
|
248
|
-
const b = parse(bs);
|
|
249
|
-
const a = parse(as);
|
|
250
|
-
if (!b || !a)
|
|
251
|
-
return false;
|
|
252
|
-
const same = (x, y) => x.length === y.length && [...x].sort().join("\u0000") === [...y].sort().join("\u0000");
|
|
253
|
-
// 値が同じで、項目名が変わっている=付け替え
|
|
254
|
-
return same(b.values, a.values) && !same(b.keys, a.keys);
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* 改名の検出。**90% 一致以上を改名とみなす。**
|
|
258
|
-
*
|
|
259
|
-
* 100%(完全一致だけ)も検討したが、**実データで唯一の「本物の改名」を取りこぼす**ことが
|
|
260
|
-
* 分かったのでやめた。カナリアの全履歴を数えた結果:
|
|
261
|
-
*
|
|
262
|
-
* 50% → 117件 / 90% → 117件 / 100% → 116件
|
|
263
|
-
* 100% だけが落とすもの: R096 gold-external-sources.json.md → dev-sources.json.md
|
|
264
|
-
* (同じ用語の正真正銘の改名。差分は id: の1行だけ。100% だと削除+追加に割れ、
|
|
265
|
-
* 削除で打ち切られて**改名前の起票が丸ごと消える**——直そうとしている症状そのもの)
|
|
266
|
-
*
|
|
267
|
-
* **50% まで下げても増える改名は1件も無い**(=この案件に「似た別レコードが繋がる」形は無い)。
|
|
268
|
-
* それでも 90% に留めるのは、**誤検出の被害が重い**から——別々の記録が繋がると
|
|
269
|
-
* 「人が消した本文が別の記録のページに出る」(0.12.15 の実装レビューで直した失敗)が再発する。
|
|
270
|
-
* Gold のレコードで別々の記録が9割一致することは事実上ないので、ここが安全と実用の折り合い。
|
|
271
|
-
*
|
|
272
|
-
* 🔴 **閾値を動かすときは readDiffs の --diff-filter と mCount の数え方も一緒に見ること。**
|
|
273
|
-
* 100% 未満の改名は**中身も変わっている**ので、差分を取って「書き換え」として出す必要がある。
|
|
274
|
-
* 揃えないと、人が「改名しながら中身も直した」変更が**無音で消える**。
|
|
275
|
-
*/
|
|
276
|
-
const RENAME_OPT = "--find-renames=90%";
|
|
277
174
|
/**
|
|
278
175
|
* git log を1回。失敗(git 無し・リポジトリでない・出力が大きすぎる)は空文字にする。
|
|
279
176
|
* @param what 警告に出す「何が取れなかったか」。呼び出し元が増えたので引数化した——
|
|
280
177
|
* 固定文のままだと Home の最終更新日が取れない日に「AIの変更履歴を…」という
|
|
281
178
|
* 嘘の警告が出て、診断がその文言に引っ張られる
|
|
282
179
|
*/
|
|
283
|
-
function gitLog(cwd, args, what = "
|
|
180
|
+
function gitLog(cwd, args, what = "git の履歴") {
|
|
284
181
|
try {
|
|
285
182
|
// 🔴 **出力の形は、読む人の git 設定に左右させない。** ここで組み立てているのは
|
|
286
183
|
// 機械が読む出力なので、人の ~/.gitconfig で形が変わる余地を全部潰す:
|
|
@@ -328,397 +225,6 @@ function gitLog(cwd, args, what = "AIの変更履歴") {
|
|
|
328
225
|
return "";
|
|
329
226
|
}
|
|
330
227
|
}
|
|
331
|
-
/** パス1: コミットの素性と、そのコミットが触ったレコードのパス(新しい順) */
|
|
332
|
-
function readCommits(cwd) {
|
|
333
|
-
const out = gitLog(cwd, [
|
|
334
|
-
"log",
|
|
335
|
-
`-n${AI_HISTORY_MAX_COMMITS}`,
|
|
336
|
-
// 🔴 **改名を検出する(0.12.16)。** --no-renames だと `Cortex/用語集/` → `Cortex/Glossary/`
|
|
337
|
-
// のようなディレクトリの引っ越しが**削除+追加**に割れ、(1) 新パスに「AIがこの記録を
|
|
338
|
-
// 作成しました」が立ち (2) 旧パスは削除扱いで**移動より前の履歴が丸ごと消える**。
|
|
339
|
-
// 追跡は collectAiChangeHistory 側で1パスで行う(--follow は単一パス専用で、
|
|
340
|
-
// レコードごとに git を呼ぶことになり「ビルド1回につき2回」を壊す)。
|
|
341
|
-
// **log.follow=true を設定している人がいる**ので --no-follow も明示する
|
|
342
|
-
RENAME_OPT,
|
|
343
|
-
"--no-follow",
|
|
344
|
-
"--no-color",
|
|
345
|
-
// パス2と揃える(--name-status はテキスト差分を作らないので発火しないが、
|
|
346
|
-
// 「読む人の git 設定に左右させない」の徹底を非対称にしない)
|
|
347
|
-
"--no-ext-diff",
|
|
348
|
-
"--name-status",
|
|
349
|
-
// 🔴 **件名(%s)は必ず最後に置く**(件名に区切り文字が混ざっても繋ぎ直せるように)。
|
|
350
|
-
// `Operated-By:` トレーラーは値だけを RS 区切りの1行で取る(改行が入ると1行1コミットが壊れる)
|
|
351
|
-
`--format=%x00%H%x1f%aI%x1f%an%x1f%ae%x1f%(trailers:key=Operated-By,valueonly,separator=%x1e)%x1f%s`,
|
|
352
|
-
"--",
|
|
353
|
-
RECORD_PATHSPEC,
|
|
354
|
-
]);
|
|
355
|
-
const commits = [];
|
|
356
|
-
let cur = null;
|
|
357
|
-
for (const line of out.split("\n")) {
|
|
358
|
-
if (line.startsWith(NUL)) {
|
|
359
|
-
const parts = line.slice(1).split(US);
|
|
360
|
-
// 件名に区切り文字が混ざっても壊れないよう、6つ目以降は件名として繋ぎ直す
|
|
361
|
-
cur = {
|
|
362
|
-
sha: parts[0] || "",
|
|
363
|
-
at: parts[1] || "",
|
|
364
|
-
name: parts[2] || "",
|
|
365
|
-
email: parts[3] || "",
|
|
366
|
-
operatedBy: parts[4] || "",
|
|
367
|
-
subject: parts.slice(5).join(US),
|
|
368
|
-
files: [],
|
|
369
|
-
};
|
|
370
|
-
if (cur.sha)
|
|
371
|
-
commits.push(cur);
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
if (!cur)
|
|
375
|
-
continue;
|
|
376
|
-
const m = /^([A-Z])\d*\t(.+)$/.exec(line);
|
|
377
|
-
if (!m)
|
|
378
|
-
continue;
|
|
379
|
-
if (m[1] === "R") {
|
|
380
|
-
// 改名は「旧パス<TAB>新パス」の2列。行頭の `R100` の数字は一致度
|
|
381
|
-
const i = m[2].indexOf("\t");
|
|
382
|
-
if (i === -1)
|
|
383
|
-
continue;
|
|
384
|
-
const score = parseInt(/^R(\d+)/.exec(line)?.[1] || "", 10);
|
|
385
|
-
cur.files.push({
|
|
386
|
-
status: "R",
|
|
387
|
-
path: m[2].slice(i + 1),
|
|
388
|
-
from: m[2].slice(0, i),
|
|
389
|
-
score: Number.isFinite(score) ? score : undefined,
|
|
390
|
-
});
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
// 🔴 **複製(C)は改名と同じ扱いにしない。** 複製は**元ファイルが残る**ので、
|
|
394
|
-
// 対応表を張ると元レコードの履歴が複製先へ移り、**元のページが空になる**。
|
|
395
|
-
// いまは `--find-copies` を渡していないので届かない(`diff.renames=copies` が
|
|
396
|
-
// 設定されていても `--find-renames` が優先されることを実測で確認済み)。
|
|
397
|
-
// あとから複製検出を足す人は、ここで扱いを決めること
|
|
398
|
-
if (m[1] === "C")
|
|
399
|
-
continue;
|
|
400
|
-
cur.files.push({ status: m[1], path: m[2] });
|
|
401
|
-
}
|
|
402
|
-
return commits;
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* パス2: 変更(M)の差分だけ。鍵は `<sha>` + NUL + `<path>`。
|
|
406
|
-
*
|
|
407
|
-
* 🔴 **遡る件数はパス1が決める(`changeCount` = パス1の窓に入った「変更または改名」のコミット数)。**
|
|
408
|
-
* `--diff-filter` は表示だけでなく**コミットの選択**にも効くので、ここで素の
|
|
409
|
-
* `-n400` を渡すと「変更のコミット 400 件」を探して**履歴の底まで歩き、その全部の
|
|
410
|
-
* パッチを生成する**——レコードの変更が少ないリポジトリほど無駄が大きく、
|
|
411
|
-
* 作った差分の大半はパス1の窓の外で一度も引かれない。
|
|
412
|
-
*
|
|
413
|
-
* パス1の窓は「レコードを触ったコミットの新しい 400 件」という連続した区間なので、
|
|
414
|
-
* その中の該当コミットは**全体で最も新しい changeCount 件と一致する**。
|
|
415
|
-
* だから `-n changeCount` で必要十分(多くも少なくもならない)。
|
|
416
|
-
* **数え方は --diff-filter と必ず揃えること**(片方だけ変えると取りこぼす)。
|
|
417
|
-
*/
|
|
418
|
-
function readDiffs(cwd, changeCount) {
|
|
419
|
-
const empty = { diffs: new Map(), modeOnly: new Set() };
|
|
420
|
-
if (changeCount <= 0)
|
|
421
|
-
return empty;
|
|
422
|
-
const out = gitLog(cwd, [
|
|
423
|
-
"log",
|
|
424
|
-
`-n${changeCount}`,
|
|
425
|
-
// パス1と**同じ改名の設定**にする(違えるとコミットの選択がずれ、mCount の前提が崩れる)
|
|
426
|
-
RENAME_OPT,
|
|
427
|
-
"--no-follow",
|
|
428
|
-
"--no-color",
|
|
429
|
-
// diff.external / GIT_EXTERNAL_DIFF が設定されていると差分が丸ごと別物になる
|
|
430
|
-
"--no-ext-diff",
|
|
431
|
-
// 接頭辞を明示して固定する(diff.mnemonicPrefix / diff.noprefix の影響を消す)
|
|
432
|
-
"--src-prefix=a/",
|
|
433
|
-
"--dst-prefix=b/",
|
|
434
|
-
"-p",
|
|
435
|
-
// 文脈行は要らない。**変わった行だけ**を出す(画面のラベルも「消えた行/増えた行」)
|
|
436
|
-
"-U0",
|
|
437
|
-
// **M だけでなく R も取る。** 100% 未満の改名は中身も変わっているので、その差分が要る
|
|
438
|
-
//(純粋な改名は R でもパッチを持たないので、ここに入っても何も増えない)
|
|
439
|
-
"--diff-filter=MR",
|
|
440
|
-
`--format=%x00%H`,
|
|
441
|
-
"--",
|
|
442
|
-
RECORD_PATHSPEC,
|
|
443
|
-
]);
|
|
444
|
-
const diffs = new Map();
|
|
445
|
-
/**
|
|
446
|
-
* 🔴 **「中身は本当に変わっていない」と言い切れるものだけを集める。**
|
|
447
|
-
* テキスト差分が出ない節は3種類あり、意味がまったく違う:
|
|
448
|
-
*
|
|
449
|
-
* old mode / new mode … **権限だけの変更**。中身は変わっていない(ここに入れる)
|
|
450
|
-
* Binary files … differ … **中身は変わったが、差分を出せない**(.gitattributes の -diff)
|
|
451
|
-
* similarity index 100% … 純粋な改名(R のスコアで分かるので、ここでは扱わない)
|
|
452
|
-
*
|
|
453
|
-
* これを区別せずに「差分が空なら落とす」とすると、`*.md` を -diff にしている案件で
|
|
454
|
-
* **本物の書き換えが丸ごと消える**(レビューで指摘された潜在の欠陥)。
|
|
455
|
-
*/
|
|
456
|
-
const modeOnly = new Set();
|
|
457
|
-
let sha = "";
|
|
458
|
-
let cur = null;
|
|
459
|
-
/** いまのファイル節の新パス(`diff --git a/X b/Y` の Y)。`+++` が来たら確定して消す */
|
|
460
|
-
let headerPath = "";
|
|
461
|
-
let sawModeLine = false;
|
|
462
|
-
/** ファイル節の終わりで、テキスト差分が出なかった理由を確定する */
|
|
463
|
-
const closeSection = () => {
|
|
464
|
-
if (headerPath && sawModeLine && sha)
|
|
465
|
-
modeOnly.add(sha + NUL + headerPath);
|
|
466
|
-
headerPath = "";
|
|
467
|
-
sawModeLine = false;
|
|
468
|
-
};
|
|
469
|
-
// 🔴 **ヘッダの見分けは「位置」で決める。行の見た目で決めない。**
|
|
470
|
-
// 差分の中身は「符号1文字 + 元の行」なので、レコードに `++ …` という行があると
|
|
471
|
-
// `+++ …` に、`-- …` があると `--- …` になる——**ファイルのヘッダと区別が付かない**。
|
|
472
|
-
// 見た目で判定すると、その行を落とすだけでなく**以降の差分が別のパスの下に入る**。
|
|
473
|
-
// 区間で見る: `diff --git` から最初の `@@` までがヘッダ、そこから次の
|
|
474
|
-
// `diff --git` までが中身。
|
|
475
|
-
let inHeader = false;
|
|
476
|
-
for (const line of out.split("\n")) {
|
|
477
|
-
if (line.startsWith(NUL)) {
|
|
478
|
-
closeSection();
|
|
479
|
-
sha = line.slice(1).trim();
|
|
480
|
-
cur = null;
|
|
481
|
-
inHeader = false;
|
|
482
|
-
continue;
|
|
483
|
-
}
|
|
484
|
-
if (line.startsWith(DIFF_HEADER)) {
|
|
485
|
-
closeSection();
|
|
486
|
-
cur = null;
|
|
487
|
-
inHeader = true;
|
|
488
|
-
// 権限だけの変更・binary は `+++` を出さないので、ここでしかパスを取れない。
|
|
489
|
-
// 接頭辞は --src-prefix / --dst-prefix で固定してあるので、` b/` の**最後の**
|
|
490
|
-
// 出現で割ればよい(レコード名に空白は入りうるが、` b/` は入らない)
|
|
491
|
-
const rest = line.slice(DIFF_HEADER.length);
|
|
492
|
-
const at = rest.lastIndexOf(" b/");
|
|
493
|
-
headerPath = at === -1 ? "" : rest.slice(at + 3);
|
|
494
|
-
continue;
|
|
495
|
-
}
|
|
496
|
-
if (inHeader) {
|
|
497
|
-
// パスは `+++ b/<path>` から取る(上の行は空白を含むパスで割れない)
|
|
498
|
-
if (line.startsWith("+++ ")) {
|
|
499
|
-
const p = line.slice(4).trim();
|
|
500
|
-
headerPath = "";
|
|
501
|
-
sawModeLine = false;
|
|
502
|
-
if (p === "/dev/null" || !sha) {
|
|
503
|
-
cur = null;
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
const path = p.startsWith("b/") ? p.slice(2) : p;
|
|
507
|
-
cur = { before: [], after: [] };
|
|
508
|
-
diffs.set(sha + NUL + path, cur);
|
|
509
|
-
continue;
|
|
510
|
-
}
|
|
511
|
-
if (line.startsWith("old mode ") || line.startsWith("new mode "))
|
|
512
|
-
sawModeLine = true;
|
|
513
|
-
// 最初のハンク見出しでヘッダは終わり(index / old mode / `---` はここで捨てる)
|
|
514
|
-
if (line.startsWith("@@"))
|
|
515
|
-
inHeader = false;
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
if (!cur)
|
|
519
|
-
continue;
|
|
520
|
-
// 2つ目以降のハンク見出し。中身の行には必ず符号が付くので、素の `@@` は見出しだけ
|
|
521
|
-
if (line.startsWith("@@"))
|
|
522
|
-
continue;
|
|
523
|
-
// `` は符号が付かないので、下の2つに落ちず自然に無視される
|
|
524
|
-
if (line.startsWith("-"))
|
|
525
|
-
cur.before.push(line.slice(1));
|
|
526
|
-
else if (line.startsWith("+"))
|
|
527
|
-
cur.after.push(line.slice(1));
|
|
528
|
-
}
|
|
529
|
-
closeSection();
|
|
530
|
-
return { diffs, modeOnly };
|
|
531
|
-
}
|
|
532
|
-
/** 長すぎる差分は丸める(site の JSON に丸ごと載るので上限が要る) */
|
|
533
|
-
function clampDiff(text) {
|
|
534
|
-
if (text.length <= AI_HISTORY_MAX_DIFF_CHARS)
|
|
535
|
-
return { text, truncated: false };
|
|
536
|
-
let end = AI_HISTORY_MAX_DIFF_CHARS;
|
|
537
|
-
// 上位サロゲートで切ると、絵文字が半分だけ残って末尾が置換文字になる。1つ戻す
|
|
538
|
-
const code = text.charCodeAt(end - 1);
|
|
539
|
-
if (code >= 0xd800 && code <= 0xdbff)
|
|
540
|
-
end -= 1;
|
|
541
|
-
return { text: text.slice(0, end), truncated: true };
|
|
542
|
-
}
|
|
543
|
-
/**
|
|
544
|
-
* レコードのリポジトリ相対パス → AIによる変更(新しい順・最大 AI_HISTORY_MAX_ENTRIES 件)。
|
|
545
|
-
*
|
|
546
|
-
* 画面は既存の `repoPathOf(rec)`(editUrl から剥がしたパス)でここを引く。
|
|
547
|
-
* **いまリポジトリに無いレコード(最新の状態が削除)は地図に入れない**——
|
|
548
|
-
* もう開けないページの履歴を配っても読む人がいない。
|
|
549
|
-
*/
|
|
550
|
-
function collectAiChangeHistory(cwd) {
|
|
551
|
-
const commits = readCommits(cwd);
|
|
552
|
-
if (!commits.length)
|
|
553
|
-
return {};
|
|
554
|
-
// パス2に渡す件数を、パス1の窓に入った「変更または改名」のコミット数から数える。
|
|
555
|
-
// **readDiffs の --diff-filter と同じ集合を数えること**(片方だけ変えると差分を取りこぼす)
|
|
556
|
-
let changeCount = 0;
|
|
557
|
-
for (const c of commits) {
|
|
558
|
-
if (c.files.some((f) => (f.status === "M" || f.status === "R") && PATHSPEC_MATCH.test(f.path)))
|
|
559
|
-
changeCount++;
|
|
560
|
-
}
|
|
561
|
-
const { diffs, modeOnly } = readDiffs(cwd, changeCount);
|
|
562
|
-
const history = {};
|
|
563
|
-
/**
|
|
564
|
-
* 🔴 **削除でそのパスの収集を打ち切る。鍵は「そのコミット時点の生パス」。**
|
|
565
|
-
*
|
|
566
|
-
* 新しい順に走っているので、削除より古い世代は**人が消したはずの内容**——同じパスに
|
|
567
|
-
* 記録が作り直されると、消したはずの本文が新しい記録のページに出てしまう。
|
|
568
|
-
*
|
|
569
|
-
* **正本パスで引いてはいけない。** 改名追跡を入れると別々の記録が同じ正本パスに写像されるので、
|
|
570
|
-
* 「削除で空いたパスに、あとから別の記録が改名で入ってくる」形で、
|
|
571
|
-
* **移動してきた生きている記録の履歴まで丸ごと消える**(実装レビューで捕まえた)。
|
|
572
|
-
* 生パスで持ち、改名を見たときに新→旧へ伝播させると、2つの形が順序で綺麗に分かれる:
|
|
573
|
-
*
|
|
574
|
-
* α 改名してから削除された(その記録は死んでいる)… `D 新` を R より先に見るので、
|
|
575
|
-
* R のとき gone に 新 が居る → 旧へ伝播して古い履歴も捨てる
|
|
576
|
-
* β 削除で空いたパスに、あとから別の記録が入った … `R 旧→新` を D より先に見るので、
|
|
577
|
-
* 伝播せず、あとから来る `D 新` は生パス 新 にだけ効く。移動してきた記録は
|
|
578
|
-
* 生パス 旧 で照合されるので生き残る
|
|
579
|
-
*/
|
|
580
|
-
const gone = new Set();
|
|
581
|
-
/**
|
|
582
|
-
* 🔴 **改名の追跡。「そのコミット時点のパス」→「いまのパス(正本)」。**
|
|
583
|
-
*
|
|
584
|
-
* 新しい順に走りながら育てる。改名 R(旧 → 新) を見たら、その時点より**古い**コミットで
|
|
585
|
-
* 旧パスに出会ったものは、いまの記録の履歴として積む。これで `--follow` と同じことを
|
|
586
|
-
* **git を1回も余分に呼ばずに**やる。改名が連鎖していても(A→B→C)順に付け替わって繋がる。
|
|
587
|
-
*/
|
|
588
|
-
const renamedTo = new Map();
|
|
589
|
-
/** そのコミット時点のパスから、いまのパス(正本)を引く */
|
|
590
|
-
const canonicalOf = (p) => renamedTo.get(p) || p;
|
|
591
|
-
/** レコードとして扱うパスか(区画の規約・雛形はレコードではない) */
|
|
592
|
-
const isRecord = (p) => {
|
|
593
|
-
if (!RECORD_PATH.test(p))
|
|
594
|
-
return false;
|
|
595
|
-
const base = p.slice(p.lastIndexOf("/") + 1).toLowerCase();
|
|
596
|
-
return base !== "readme.md" && base !== "template.md";
|
|
597
|
-
};
|
|
598
|
-
for (const c of commits) {
|
|
599
|
-
const kind = classifyCommit(c);
|
|
600
|
-
/**
|
|
601
|
-
* このコミットの改名。**対応表への反映は、通常のエントリを処理し終えてから行う。**
|
|
602
|
-
* 途中で書き換えると、同じコミットの他のエントリの正本が git の出力順に左右される。
|
|
603
|
-
* `-M` だけなら実際には起こらない(R の元/先は同じコミットの他のエントリに現れない)が、
|
|
604
|
-
* あとから `-C` を足した瞬間に静かに壊れるので、順序を不変条件として固定しておく。
|
|
605
|
-
*/
|
|
606
|
-
const renames = [];
|
|
607
|
-
for (const f of c.files) {
|
|
608
|
-
if (f.status === "R" && f.from) {
|
|
609
|
-
const canon = canonicalOf(f.path);
|
|
610
|
-
renames.push({ from: f.from, to: f.path, canon });
|
|
611
|
-
// **中身も変わった改名(100% 未満)だけを「書き換え」として出す。**
|
|
612
|
-
// 一致度 100 は**中身が1文字も変わっていない移動**——記録の内容の変化ではないので
|
|
613
|
-
// 履歴の点にしない。**スコアで判定する**(差分の有無で判定すると、差分が
|
|
614
|
-
// 取れなかっただけの変更と区別が付かない)
|
|
615
|
-
if (kind === "ai" && isRecord(canon) && !gone.has(f.path) && f.score !== 100) {
|
|
616
|
-
pushEntry(canon, c, f.path, "edit");
|
|
617
|
-
}
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
const canon = canonicalOf(f.path);
|
|
621
|
-
if (!isRecord(canon))
|
|
622
|
-
continue;
|
|
623
|
-
if (gone.has(f.path))
|
|
624
|
-
continue;
|
|
625
|
-
if (f.status === "D") {
|
|
626
|
-
gone.add(f.path);
|
|
627
|
-
continue;
|
|
628
|
-
}
|
|
629
|
-
// 履歴に出すのは AI がやったことだけ(人の操作・データ構造の引っ越しは出さない)
|
|
630
|
-
if (kind !== "ai")
|
|
631
|
-
continue;
|
|
632
|
-
// A(作成)と M(変更)だけ。T(ファイル↔シンボリックリンクの置き換え)・
|
|
633
|
-
// C(複製・そもそも検出していない)等は「何が変わったか」を言えないので出さない
|
|
634
|
-
if (f.status !== "A" && f.status !== "M")
|
|
635
|
-
continue;
|
|
636
|
-
pushEntry(canon, c, f.path, f.status === "A" ? "add" : "edit");
|
|
637
|
-
}
|
|
638
|
-
for (const r of renames) {
|
|
639
|
-
// α: 改名先が既に死んでいるなら、改名元も死んでいる(古い履歴を捨てる)
|
|
640
|
-
if (gone.has(r.to))
|
|
641
|
-
gone.add(r.from);
|
|
642
|
-
// 改名先の名前は、これより古いコミットでは**別のもの**を指す。対応表から外す。
|
|
643
|
-
// ⚠️ **この1行はテストで守れていない**(外しても全テストが緑のまま)。外しても症状が
|
|
644
|
-
// 出ないのは、名前が再利用される形では手前の `gone` が先に効いてしまうため。
|
|
645
|
-
// 消さないでほしい: 連鎖する改名で、古い側の同名ファイルが新しい記録に
|
|
646
|
-
// 紐づく余地を残さないための一行
|
|
647
|
-
renamedTo.delete(r.to);
|
|
648
|
-
// 🔴 **写像を張れない改名は、旧パスをそこで打ち切る。**
|
|
649
|
-
// レコードの形をしていないところへ(`records/2026/A.md` のようなサブフォルダ・
|
|
650
|
-
// `README.md`)出ていった、あるいはそこから来た改名で、旧パスを生かしたままにすると、
|
|
651
|
-
// **旧パスは「もう無いのに生きている」**状態になる。そこに新しい記録が作り直されると、
|
|
652
|
-
// **前の記録の作成・本文が新しい記録のページに出る**——0.12.15 で直した
|
|
653
|
-
// 「人が消した本文が別の記録に出る」と同じ型(レビューで実測・再現された)
|
|
654
|
-
if (isRecord(r.canon) && isRecord(r.from))
|
|
655
|
-
renamedTo.set(r.from, r.canon);
|
|
656
|
-
else
|
|
657
|
-
gone.add(r.from);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
/** 1件を積む。**中身が変わっていない「書き換え」は積まない** */
|
|
661
|
-
function pushEntry(canon, c, rawPath, kind) {
|
|
662
|
-
const list = history[canon] || (history[canon] = []);
|
|
663
|
-
if (list.length >= exports.AI_HISTORY_MAX_ENTRIES)
|
|
664
|
-
return;
|
|
665
|
-
let before = "";
|
|
666
|
-
let after = "";
|
|
667
|
-
let truncated = false;
|
|
668
|
-
const key = c.sha + NUL + rawPath;
|
|
669
|
-
// **「追加(A)の差分を載せない」の門は readDiffs の --diff-filter ただ1つ。**
|
|
670
|
-
// ここでも status を見て二重に守ると、どちらを外しても症状が出ず、
|
|
671
|
-
// **テストで守れない不変条件**になる(実際に変異検証で素通りした)。門は1箇所に置く
|
|
672
|
-
const d = diffs.get(key);
|
|
673
|
-
if (d) {
|
|
674
|
-
const b = clampDiff(d.before.join("\n"));
|
|
675
|
-
const a = clampDiff(d.after.join("\n"));
|
|
676
|
-
before = b.text;
|
|
677
|
-
after = a.text;
|
|
678
|
-
truncated = b.truncated || a.truncated;
|
|
679
|
-
}
|
|
680
|
-
if (kind === "edit") {
|
|
681
|
-
// 🔴 **構造の規則A: 中身が変わっていないと「言い切れる」変更だけを落とす。**
|
|
682
|
-
//
|
|
683
|
-
// ⚠️ **「差分が空」と「差分が取れなかった」を同じにしてはいけない**(レビュー指摘)。
|
|
684
|
-
// テキスト差分が出ない理由は3つあり、意味がまったく違う:
|
|
685
|
-
//
|
|
686
|
-
// ① 権限だけの変更 …… 中身は変わっていない(git が old mode/new mode で明言)→ 落とす
|
|
687
|
-
// ② 純粋な改名 …………… 中身は変わっていない(R のスコア100で判定済み)→ ここへ来ない
|
|
688
|
-
// ③ 取れなかった ……… binary 扱い(.gitattributes の -diff)・パス2の窓の外・
|
|
689
|
-
// パス2そのものの失敗。**中身は変わっているかもしれない**
|
|
690
|
-
//
|
|
691
|
-
// ③を落とすと、`*.md` を -diff にしている案件で**本物の書き換えが丸ごと消え**、
|
|
692
|
-
// パス2が失敗した日には**全案件で履歴が無言で全滅**する。だから③は落とさず、
|
|
693
|
-
// 差分の代わりに画面へ「出せませんでした」と断らせる
|
|
694
|
-
if (!before && !after && modeOnly.has(key))
|
|
695
|
-
return;
|
|
696
|
-
// 🔴 **構造の規則B: フロントマターの項目名だけの付け替えは出さない。**
|
|
697
|
-
// データ構造の引っ越しであって内容の変化ではない(件名の規則の裏取り)。
|
|
698
|
-
// ⚠️ ただし裏取りできるのは**実データで見た2つの形のうち1つだけ**
|
|
699
|
-
//(`summary:`→`description:` の169件。`status:` の後付け300件は件名の規則だけが頼り)
|
|
700
|
-
if (isFrontmatterKeyRename(before, after))
|
|
701
|
-
return;
|
|
702
|
-
}
|
|
703
|
-
const entry = {
|
|
704
|
-
commit: c.sha,
|
|
705
|
-
at: c.at,
|
|
706
|
-
subject: c.subject,
|
|
707
|
-
kind,
|
|
708
|
-
before,
|
|
709
|
-
after,
|
|
710
|
-
};
|
|
711
|
-
if (truncated)
|
|
712
|
-
entry.truncated = true;
|
|
713
|
-
list.push(entry);
|
|
714
|
-
}
|
|
715
|
-
// 空の器は返さない(「知らないことはキーが無いことで表す」)
|
|
716
|
-
for (const p of Object.keys(history)) {
|
|
717
|
-
if (!history[p].length)
|
|
718
|
-
delete history[p];
|
|
719
|
-
}
|
|
720
|
-
return history;
|
|
721
|
-
}
|
|
722
228
|
// ---- Home の「実質的な最終更新日」 ------------------------------------------
|
|
723
229
|
//
|
|
724
230
|
// Home タイトル横の「最終更新: YYYY-MM-DD」の出どころ。Home の自己記述(この案件に
|
|
@@ -735,12 +241,12 @@ function collectAiChangeHistory(cwd) {
|
|
|
735
241
|
// 嘘をつくのは目的そのものへの裏切りで、見た目が自然なので誰も気づけない。
|
|
736
242
|
// 既知の例外が1つ: **権限(mode)だけのコミット**は --name-status では M と区別が
|
|
737
243
|
// 付かず、実質更新に数えてしまう。区別には差分をもう1回 git から取る必要があり
|
|
738
|
-
//
|
|
739
|
-
//
|
|
244
|
+
// 、Home.md にモードだけの変更が入る形は実質
|
|
245
|
+
// 無いので、ここでは扱わないと決めた
|
|
740
246
|
/** 遡るコミット数の上限。Home.md 1ファイルの履歴なので、これで実質十分 */
|
|
741
247
|
const LAST_UPDATE_MAX_COMMITS = 50;
|
|
742
248
|
/**
|
|
743
|
-
* 改名の検出閾値。🔴
|
|
249
|
+
* 改名の検出閾値。🔴 **記録の履歴の検出(以前は 90%)と共有しない。50%(git の既定)を明示する。**
|
|
744
250
|
*
|
|
745
251
|
* 90% はレコード群の「誤検出=人が消した本文が別の記録のページに出る」という重い事故を
|
|
746
252
|
* 避けるための数字。ここは対象が Home.md 1ファイルで、誤検出の被害は「日付が少し
|
|
@@ -759,16 +265,14 @@ const LAST_UPDATE_RENAME_OPT = "--find-renames=50%";
|
|
|
759
265
|
* 示す鮮度のヒントだから。
|
|
760
266
|
*
|
|
761
267
|
* 日付は author date(%aI)の先頭10文字。committer date にしないのは rebase や
|
|
762
|
-
*
|
|
763
|
-
* 設定にも左右されない)。タイムゾーンは書いた人のオフセット基準——bot の UTC 編集が
|
|
268
|
+
* マージで日付が動くため(読む人の log.date 設定にも左右されない)。タイムゾーンは書いた人のオフセット基準——bot の UTC 編集が
|
|
764
269
|
* JST より1日古く出ることがあるが、それは「古い方向」なので許容する。
|
|
765
270
|
*
|
|
766
271
|
* git が無い・リポジトリでない・浅い clone・窓内が全部機械的 → null
|
|
767
272
|
* (表示が出ないだけで、他は壊れない)。
|
|
768
273
|
*
|
|
769
|
-
* frontmatter
|
|
770
|
-
*
|
|
771
|
-
* 件名規約で落ちる形しか観測されていない(適用しないと決めた・設計レビュー承認済み)。
|
|
274
|
+
* frontmatter の項目名だけの付け替えを落とす構造の規則は使わない——差分の取得にもう1回 git を
|
|
275
|
+
* 呼ぶ割に、Home.md ではマイグレーションの件名規約で落ちる形しか観測されていない。
|
|
772
276
|
*/
|
|
773
277
|
function lastSubstantiveUpdate(cwd, repoPath) {
|
|
774
278
|
// 🔴 **浅い clone を最初に弾く。** 浅い clone では境界コミットが親を持たないため、
|
|
@@ -789,8 +293,7 @@ function lastSubstantiveUpdate(cwd, repoPath) {
|
|
|
789
293
|
catch {
|
|
790
294
|
return null; // git が無い・リポジトリでない(既定の状態なので警告もしない)
|
|
791
295
|
}
|
|
792
|
-
// 単一ファイルなので --follow で改名を git
|
|
793
|
-
// なのは複数レコードを1回の log で読むため。ここはその制約が当てはまらない)
|
|
296
|
+
// 単一ファイルなので --follow で改名を git 側に追跡させる
|
|
794
297
|
const out = gitLog(cwd, [
|
|
795
298
|
"log",
|
|
796
299
|
`-n${LAST_UPDATE_MAX_COMMITS}`,
|