@takagaki/cortex-decisions-viewer 0.12.17 → 0.12.19

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/repo.js CHANGED
@@ -7,6 +7,7 @@ exports.normalizeToHttps = normalizeToHttps;
7
7
  exports.classifyCommit = classifyCommit;
8
8
  exports.isFrontmatterKeyRename = isFrontmatterKeyRename;
9
9
  exports.collectAiChangeHistory = collectAiChangeHistory;
10
+ exports.lastSubstantiveUpdate = lastSubstantiveUpdate;
10
11
  const node_child_process_1 = require("node:child_process");
11
12
  /**
12
13
  * GitHubのowner/repoを表すベースURLを解決する。
@@ -250,8 +251,13 @@ function isFrontmatterKeyRename(before, after) {
250
251
  * 揃えないと、人が「改名しながら中身も直した」変更が**無音で消える**。
251
252
  */
252
253
  const RENAME_OPT = "--find-renames=90%";
253
- /** git log を1回。失敗(git 無し・リポジトリでない・出力が大きすぎる)は空文字にする */
254
- function gitLog(cwd, args) {
254
+ /**
255
+ * git log を1回。失敗(git 無し・リポジトリでない・出力が大きすぎる)は空文字にする。
256
+ * @param what 警告に出す「何が取れなかったか」。呼び出し元が増えたので引数化した——
257
+ * 固定文のままだと Home の最終更新日が取れない日に「AIの変更履歴を…」という
258
+ * 嘘の警告が出て、診断がその文言に引っ張られる
259
+ */
260
+ function gitLog(cwd, args, what = "AIの変更履歴") {
255
261
  try {
256
262
  // 🔴 **出力の形は、読む人の git 設定に左右させない。** ここで組み立てているのは
257
263
  // 機械が読む出力なので、人の ~/.gitconfig で形が変わる余地を全部潰す:
@@ -294,7 +300,7 @@ function gitLog(cwd, args) {
294
300
  // 戻り値の契約(空文字 = 履歴なしで正常継続)はどちらでも変えない
295
301
  const err = e;
296
302
  if (err.code !== "ENOENT" && err.status !== 128) {
297
- console.warn(`AIの変更履歴を git から取れませんでした(履歴は出さずに続けます): ${String(e)}`);
303
+ console.warn(`${what}を git から取れませんでした(表示は出さずに続けます): ${String(e)}`);
298
304
  }
299
305
  return "";
300
306
  }
@@ -687,3 +693,126 @@ function collectAiChangeHistory(cwd) {
687
693
  }
688
694
  return history;
689
695
  }
696
+ // ---- Home の「実質的な最終更新日」 ------------------------------------------
697
+ //
698
+ // Home タイトル横の「最終更新: YYYY-MM-DD」の出どころ。Home の自己記述(この案件に
699
+ // ついて/ゴール/体制)は古くなるのに、画面にいつ時点の情報か出ていない——
700
+ // 手書きの日付は更新し忘れで嘘になるので、git のコミット日から自動で導く。
701
+ //
702
+ // ## この機能で許せない劣化の方向
703
+ //
704
+ // - 「出ない」は許容(git 無し・浅い clone・窓切れ → null。既存機能と同じ倒し方)
705
+ // - 「古めに出る」は許容(--follow は衝突解決マージを丸ごと落とすため、その形の
706
+ // 編集は1つ前の実質コミットの日付になる。squash・通常の PR マージはブランチ側の
707
+ // 編集コミットが本物の author date を持つので実害は小さい——知っていて許容した)
708
+ // - 🔴 **「新しすぎる日付」だけは許さない。** 鮮度を知らせる表示が「いつも新鮮です」と
709
+ // 嘘をつくのは目的そのものへの裏切りで、見た目が自然なので誰も気づけない。
710
+ // 既知の例外が1つ: **権限(mode)だけのコミット**は --name-status では M と区別が
711
+ // 付かず、実質更新に数えてしまう。区別には差分をもう1回 git から取る必要があり
712
+ // (AI変更履歴側の modeOnly がそれ)、Home.md にモードだけの変更が入る形は実質
713
+ // 無いので、ここでは扱わないと決めた(isFrontmatterKeyRename の見送りと同じ理由)
714
+ /** 遡るコミット数の上限。Home.md 1ファイルの履歴なので、これで実質十分 */
715
+ const LAST_UPDATE_MAX_COMMITS = 50;
716
+ /**
717
+ * 改名の検出閾値。🔴 **既存の RENAME_OPT(90%)を共有しない。50%(git の既定)を明示する。**
718
+ *
719
+ * 90% はレコード群の「誤検出=人が消した本文が別の記録のページに出る」という重い事故を
720
+ * 避けるための数字。ここは対象が Home.md 1ファイルで、誤検出の被害は「日付が少し
721
+ * 古めに出る」だけ——**リスクの非対称性が逆向き**なので、値だけ持ってきても根拠は
722
+ * 付いてこない。実測でも「旧構成 → Cortex/Home.md への引っ越し(見出しを整えて
723
+ * 52% 類似)」は 90% だと追加+削除に割れて追跡が切れ、50% なら R052 で追える。
724
+ * 省略せず明示するのは、diff.renames を切っている人の設定に左右されないため。
725
+ */
726
+ const LAST_UPDATE_RENAME_OPT = "--find-renames=50%";
727
+ /**
728
+ * 指定ファイルの「実質的な最終更新日」(YYYY-MM-DD)。
729
+ *
730
+ * 機械的なコミット(classifyCommit が "migration" と判定するもの)と、中身が
731
+ * 1文字も変わっていない移動(R100)は飛ばす。**人の編集と AI の編集はどちらも
732
+ * 「実質更新」として数える**——この表示は「誰が」ではなく「情報がいつ時点か」を
733
+ * 示す鮮度のヒントだから。
734
+ *
735
+ * 日付は author date(%aI)の先頭10文字。committer date にしないのは rebase や
736
+ * マージで日付が動くため(既存 AiChangeEntry.at と同じ選択で、読む人の log.date
737
+ * 設定にも左右されない)。タイムゾーンは書いた人のオフセット基準——bot の UTC 編集が
738
+ * JST より1日古く出ることがあるが、それは「古い方向」なので許容する。
739
+ *
740
+ * git が無い・リポジトリでない・浅い clone・窓内が全部機械的 → null
741
+ * (表示が出ないだけで、他は壊れない)。
742
+ *
743
+ * frontmatter の項目名だけの付け替えを落とす構造の規則(isFrontmatterKeyRename)は
744
+ * ここでは使わない——差分の取得にもう1回 git を呼ぶ割に、Home.md ではマイグレーションの
745
+ * 件名規約で落ちる形しか観測されていない(適用しないと決めた・設計レビュー承認済み)。
746
+ */
747
+ function lastSubstantiveUpdate(cwd, repoPath) {
748
+ // 🔴 **浅い clone を最初に弾く。** 浅い clone では境界コミットが親を持たないため、
749
+ // このファイルを触っていない直近のコミットまで `A <path>` として現れる(実測)。
750
+ // そのまま読むと「最終更新: 今日」という**常に新しい嘘の日付**になる——既存機能は
751
+ // 浅い clone で「出ない」に倒れるが、この計算だけは「嘘をつく」に倒れるので、
752
+ // ガードで丸ごと null にする。「A を全部飛ばす」で代用しないこと(本物の初回
753
+ // コミットは正当な A で、それを飛ばすと作られたきりの Home に日付が出ない)
754
+ try {
755
+ const shallow = (0, node_child_process_1.execFileSync)("git", ["rev-parse", "--is-shallow-repository"], {
756
+ cwd,
757
+ encoding: "utf8",
758
+ stdio: ["ignore", "pipe", "ignore"],
759
+ }).trim();
760
+ if (shallow !== "false")
761
+ return null;
762
+ }
763
+ catch {
764
+ return null; // git が無い・リポジトリでない(既定の状態なので警告もしない)
765
+ }
766
+ // 単一ファイルなので --follow で改名を git 側に追跡させる(readCommits が --no-follow
767
+ // なのは複数レコードを1回の log で読むため。ここはその制約が当てはまらない)
768
+ const out = gitLog(cwd, [
769
+ "log",
770
+ `-n${LAST_UPDATE_MAX_COMMITS}`,
771
+ "--follow",
772
+ LAST_UPDATE_RENAME_OPT,
773
+ "--no-color",
774
+ "--no-ext-diff",
775
+ "--name-status",
776
+ `--format=%x00%H%x1f%aI%x1f%an%x1f%ae%x1f%s`,
777
+ "--",
778
+ repoPath,
779
+ ], "Homeの最終更新日");
780
+ // 🔴 走査は「**飛ばされなかったファイル行を1つ以上持つ**、最も新しいコミット」を探す。
781
+ // ファイル行の無いコミット(素の log では衝突解決マージがこの形で出る)を実質更新に
782
+ // 数えない——いまは --follow がその形を落とすので踏まないが、「たまたま踏まない」を
783
+ // 不変条件にしない(--follow を外すフォールバックを将来足した瞬間に無音で壊れる)。
784
+ //
785
+ // ⚠️ **ファイル行のパスを repoPath と突き合わせて絞ってはいけない。** --follow では
786
+ // 改名より古いコミットのパスが**その時点の古いパス**で出る。目的パスで絞ると、
787
+ // 追跡してきた過去が丸ごと消える(絞りたくなっても締めないこと)。
788
+ let at = "";
789
+ let skip = true; // 先頭(コミット行より前)には何も無い
790
+ let hasSubstantiveFile = false;
791
+ const settle = () => !skip && hasSubstantiveFile && at.length >= 10 ? at.slice(0, 10) : null;
792
+ for (const line of out.split("\n")) {
793
+ if (line.startsWith(NUL)) {
794
+ const done = settle();
795
+ if (done)
796
+ return done;
797
+ const parts = line.slice(1).split(US);
798
+ at = parts[1] || "";
799
+ skip = classifyCommit({
800
+ name: parts[2] || "",
801
+ email: parts[3] || "",
802
+ subject: parts.slice(4).join(US),
803
+ }) === "migration";
804
+ hasSubstantiveFile = false;
805
+ continue;
806
+ }
807
+ // name-status 行だけを拾う(ヘッダとの間の空行はここで自然に落ちる)
808
+ const m = /^([A-Z])(\d*)\t/.exec(line);
809
+ if (!m)
810
+ continue;
811
+ if (m[1] === "D")
812
+ continue; // 防御。HEAD に居るファイルの --follow では実質出ない
813
+ if (m[1] === "R" && m[2] === "100")
814
+ continue; // 中身が1文字も変わっていない移動
815
+ hasSubstantiveFile = true;
816
+ }
817
+ return settle();
818
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -27,10 +27,12 @@
27
27
  "dependencies": {
28
28
  "commander": "^12.1.0",
29
29
  "gray-matter": "^4.0.3",
30
- "marked": "^12.0.0"
30
+ "marked": "^12.0.0",
31
+ "sanitize-html": "^2.17.7"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/node": "^22.10.0",
35
+ "@types/sanitize-html": "^2.16.1",
34
36
  "jsdom": "^29.1.1",
35
37
  "linkedom": "^0.18.13",
36
38
  "typescript": "^5.7.0"