@takagaki/cortex-decisions-viewer 0.12.75 → 0.12.77
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 +66 -56
- package/dist/external-id.js +148 -0
- package/dist/rederivation.js +132 -18
- package/dist/render.js +696 -124
- package/dist/repo.js +80 -0
- package/package.json +1 -1
- package/dist/person-redact.js +0 -244
package/dist/build.js
CHANGED
|
@@ -57,35 +57,9 @@ const marked_1 = require("marked");
|
|
|
57
57
|
const sanitize_html_1 = __importDefault(require("sanitize-html"));
|
|
58
58
|
const repo_1 = require("./repo");
|
|
59
59
|
const rederivation_1 = require("./rederivation");
|
|
60
|
-
const person_redact_1 = require("./person-redact");
|
|
61
60
|
// 直近のGold昇格で追加されたレコードのパス集合。レコード生成の各所から参照するのでモジュールスコープに置く
|
|
62
61
|
// (引数で引き回すと呼び出し側の改修が広範囲になる)。build() の冒頭で1回だけ解決する。
|
|
63
62
|
let LATEST_PATHS = new Set();
|
|
64
|
-
/**
|
|
65
|
-
* メンバーの記録から落としたメールアドレス・外の ID の件数(#681)。
|
|
66
|
-
*
|
|
67
|
-
* **ビルドログに出す。** 実データ(案件リポジトリ)はこのリポジトリから触れないので、
|
|
68
|
-
* 「どれだけ隠したか」を数えられる口はここしかない。配布後に Actions のログから
|
|
69
|
-
* 全案件ぶんが拾える(発注書 3 の「見つけた数だけ報告する」)。
|
|
70
|
-
*/
|
|
71
|
-
const memberRedaction = {
|
|
72
|
-
records: 0,
|
|
73
|
-
dropped: 0,
|
|
74
|
-
reset() {
|
|
75
|
-
this.records = 0;
|
|
76
|
-
this.dropped = 0;
|
|
77
|
-
},
|
|
78
|
-
record(dropped) {
|
|
79
|
-
this.records++;
|
|
80
|
-
this.dropped += dropped;
|
|
81
|
-
},
|
|
82
|
-
report() {
|
|
83
|
-
// 落とすものが 1 つも無かった案件では黙る(毎晩「0 個落としました」を出さない)
|
|
84
|
-
if (!this.dropped)
|
|
85
|
-
return;
|
|
86
|
-
console.warn(`メンバーの記録 ${this.records} 件から、メールアドレスと外の ID を ${this.dropped} 個落としました(画面にも配るデータにも出しません)`);
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
63
|
/**
|
|
90
64
|
* このレコードが「直近のGold昇格で追加されたもの」か。
|
|
91
65
|
*
|
|
@@ -698,16 +672,61 @@ async function readFleetSources(repoRoot) {
|
|
|
698
672
|
* 🔴 **突き合わせはビルド時にここで閉じる**(`proposals` と同じ)。画面側でパスを組み立て直すと、
|
|
699
673
|
* `repoPathOf` が `editUrl` 由来なので **`repoBaseUrl` が無い案件で帯が黙って消える**。
|
|
700
674
|
*/
|
|
701
|
-
function attachRederived(records,
|
|
675
|
+
async function attachRederived(records, recordsDir, ctx, nowIso) {
|
|
702
676
|
if (!ctx)
|
|
703
677
|
return records;
|
|
678
|
+
// 🔴 **レコードのパスは `repoRoot` から作る**(`toRepoRelative` の `process.cwd()` 起点で
|
|
679
|
+
// 作らない)。帳簿のキーは常にリポジトリルート相対(`Cortex/Glossary/records/…`)なので、
|
|
680
|
+
// Decisions ディレクトリから CLI を叩いた案件では cwd 起点だと 1 件も突き合わない
|
|
681
|
+
const recordsRel = path.relative(ctx.repoRoot, recordsDir).split(path.sep).join("/");
|
|
704
682
|
const pathOf = (r) => (recordsRel ? `${recordsRel}/${r.fileName}` : r.fileName);
|
|
705
683
|
// 🔴 **「確認済み」は `status` だけで決まる**(#667)。帯も並びも同じ `rstatus` を見る
|
|
706
684
|
// ——ここで別のものを渡すと、画面の札と食い違う(「未確認」の札が付いた記録に帯が出る)
|
|
707
685
|
const statusOf = (r) => r.rstatus;
|
|
686
|
+
// 決定表の行 8(中身が変わったか)の材料。**印の候補になったレコードのぶんだけ**、
|
|
687
|
+
// `<基準コミット>:<path>` をまとめて 1 回引く。
|
|
688
|
+
// 🔴 **キーが 0 件の区画では git を起こさない**(この関数は区画ごとに呼ばれる)
|
|
689
|
+
const keyOf = new Map();
|
|
690
|
+
for (const r of records) {
|
|
691
|
+
const key = (0, rederivation_1.rederivedContentKey)(pathOf(r), statusOf(r), ctx.ledgers);
|
|
692
|
+
if (key)
|
|
693
|
+
keyOf.set(r, key);
|
|
694
|
+
}
|
|
695
|
+
const bases = keyOf.size ? (0, repo_1.readBlobsAt)(ctx.repoRoot, [...keyOf.values()]) : new Map();
|
|
696
|
+
// 今の版は**作業ツリーから読む**——画面が描いているのはこのファイルであって HEAD ではない
|
|
697
|
+
const now = new Map();
|
|
698
|
+
for (const r of keyOf.keys()) {
|
|
699
|
+
try {
|
|
700
|
+
now.set(pathOf(r), await node_fs_1.promises.readFile(path.join(ctx.repoRoot, pathOf(r)), "utf8"));
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// 読めなければ「引けなかった」側(下の verdict が unknown に倒す)
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const verdict = (p, baseCommit) => {
|
|
707
|
+
const nowRaw = now.get(p);
|
|
708
|
+
if (nowRaw == null) {
|
|
709
|
+
ctx.miss.unreadable.add(p);
|
|
710
|
+
return "unknown";
|
|
711
|
+
}
|
|
712
|
+
// 🔴 **帳簿が中身の指紋を持つならそれを先に使う**(engine が後の便で書く予定)。
|
|
713
|
+
// 一致したときだけ「変わっていない」と言い、食い違ったら git の二段目へ落とす——
|
|
714
|
+
// engine が別の綴りで指紋を作っていた場合に、全レコードへ誤報が出るのを避ける
|
|
715
|
+
const conf = ctx.ledgers.confirmation.get(p);
|
|
716
|
+
if (conf && conf.contentSha && conf.contentSha === (0, rederivation_1.contentShaOf)(nowRaw))
|
|
717
|
+
return "same";
|
|
718
|
+
const baseRaw = bases.get(`${baseCommit}:${p}`);
|
|
719
|
+
if (baseRaw == null) {
|
|
720
|
+
// 浅い clone の窓の外・確認の後に改名された・基準コミットにこの path が無い。
|
|
721
|
+
// **印は立てない**(誤報より無言)が、静かに消える経路なのでログの器に数える
|
|
722
|
+
ctx.miss.unreadable.add(p);
|
|
723
|
+
return "unknown";
|
|
724
|
+
}
|
|
725
|
+
return (0, rederivation_1.sameRederivedContent)(baseRaw, nowRaw) ? "same" : "changed";
|
|
726
|
+
};
|
|
708
727
|
for (const r of records) {
|
|
709
|
-
const band = (0, rederivation_1.rederivedBandOf)(pathOf(r), statusOf(r), ctx.ledgers, ctx.identities, ctx.miss);
|
|
710
|
-
//
|
|
728
|
+
const band = (0, rederivation_1.rederivedBandOf)(pathOf(r), statusOf(r), ctx.ledgers, ctx.identities, verdict, ctx.miss);
|
|
729
|
+
// **印が立たないレコードにはキーごと付けない**(空の器を通さない)
|
|
711
730
|
if (band)
|
|
712
731
|
r.rederived = band;
|
|
713
732
|
}
|
|
@@ -946,10 +965,7 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
|
|
|
946
965
|
if (exclude.has(e.name.toLowerCase()))
|
|
947
966
|
continue;
|
|
948
967
|
const raw = await node_fs_1.promises.readFile(path.join(dirAbs, e.name), "utf8");
|
|
949
|
-
|
|
950
|
-
// ——`repoRelDir` は `process.cwd()` 相対で、cwd 次第で前置きが落ちる(#681)
|
|
951
|
-
const sectionDir = path.basename(path.dirname(path.resolve(dirAbs)));
|
|
952
|
-
records.push(await parseGeneric(raw, e.name, repoRelDir, repoBaseUrl, branch, sectionDir));
|
|
968
|
+
records.push(await parseGeneric(raw, e.name, repoRelDir, repoBaseUrl, branch));
|
|
953
969
|
}
|
|
954
970
|
// 日付降順 → タイトル昇順(用語集のように日付が同一でも安定して並ぶ)
|
|
955
971
|
records.sort((a, b) => {
|
|
@@ -959,7 +975,7 @@ async function loadGenericRecords(dirAbs, exclude, repoBaseUrl, branch) {
|
|
|
959
975
|
});
|
|
960
976
|
return records;
|
|
961
977
|
}
|
|
962
|
-
async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch
|
|
978
|
+
async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch) {
|
|
963
979
|
const { data, content } = (0, gray_matter_1.default)(raw);
|
|
964
980
|
const h1 = content.match(/^#\s+(.+)$/m);
|
|
965
981
|
const id = String(data.id ?? fileName.replace(/\.md$/i, ""));
|
|
@@ -979,14 +995,6 @@ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch, sect
|
|
|
979
995
|
const editUrl = repoBaseUrl
|
|
980
996
|
? `${repoBaseUrl}/edit/${branch}/${encodePath(filePath)}`
|
|
981
997
|
: null;
|
|
982
|
-
// メンバーの記録は、メールアドレスと外の ID を**配るデータに載せない**(#681)。
|
|
983
|
-
// 画面側ではなくここで落とす——`fm` は `JSON.stringify` でページのソースに丸ごと埋まる
|
|
984
|
-
const sanitized = sanitizeFm(data);
|
|
985
|
-
const isMember = (0, person_redact_1.isMemberRecord)(data.type, data.id, sectionDir);
|
|
986
|
-
const redacted = isMember ? (0, person_redact_1.redactMemberFm)(sanitized) : null;
|
|
987
|
-
if (redacted)
|
|
988
|
-
memberRedaction.record(redacted.dropped);
|
|
989
|
-
const proposals = takeProposals(filePath);
|
|
990
998
|
return {
|
|
991
999
|
id,
|
|
992
1000
|
title,
|
|
@@ -1001,13 +1009,11 @@ async function parseGeneric(raw, fileName, repoRelDir, repoBaseUrl, branch, sect
|
|
|
1001
1009
|
rtype: data.type != null ? String(data.type) : "",
|
|
1002
1010
|
rstatus: data.status != null ? String(data.status) : "",
|
|
1003
1011
|
isLatest: isLatestPath(filePath),
|
|
1004
|
-
proposals:
|
|
1005
|
-
// 🔴 `relations` / `source` には形の判定を当てない。中身は内側の参照(`minute:` 等)で、
|
|
1006
|
-
// 当てるとメンバー → 議事録・共有資料・デザインの線がまるごと消える(設計レビュー🔴2)
|
|
1012
|
+
proposals: takeProposals(filePath),
|
|
1007
1013
|
relations: toRelations(data.relations),
|
|
1008
1014
|
source: data.source != null ? String(data.source) : "",
|
|
1009
1015
|
bodyLinks: extractBodyLinks(content),
|
|
1010
|
-
fm:
|
|
1016
|
+
fm: sanitizeFm(data),
|
|
1011
1017
|
fmErrors: validateFm(data),
|
|
1012
1018
|
};
|
|
1013
1019
|
}
|
|
@@ -1940,7 +1946,6 @@ async function build(opts) {
|
|
|
1940
1946
|
// 同一プロセスで2回ビルドしても前回の値が残らないよう、毎回入れ替える
|
|
1941
1947
|
PROPOSALS_BY_PATH = await readGoldProposals(repoRoot);
|
|
1942
1948
|
PROPOSAL_PATHS_MATCHED = new Set();
|
|
1943
|
-
memberRedaction.reset();
|
|
1944
1949
|
// 再導出の 3 帳簿(#665 便 12b)。**帳簿が無い案件では帯も並び替えも作らない**ので、
|
|
1945
1950
|
// 読めたときだけ git の素性を取りに行く(帳簿を持たない 44 案件で git の呼び出しを増やさない)
|
|
1946
1951
|
const ledgers = await (0, rederivation_1.readRederivationLedgers)(repoRoot);
|
|
@@ -1951,8 +1956,9 @@ async function build(opts) {
|
|
|
1951
1956
|
// あちらは「リポジトリルートで実行すること」が前提の既存の制約だが、こちらは repoRoot を
|
|
1952
1957
|
// 既に知っている(帳簿をそこから読んでいる)。Decisions ディレクトリから CLI を叩いた
|
|
1953
1958
|
// ときも pathspec が当たり、帯が黙って消えない
|
|
1959
|
+
repoRoot: repoRoot,
|
|
1954
1960
|
identities: ledgers.confirmation.size ? (0, repo_1.collectGoldCommitIdentities)(repoRoot) : new Map(),
|
|
1955
|
-
miss: { unresolved: new Set() },
|
|
1961
|
+
miss: { unresolved: new Set(), unreadable: new Set() },
|
|
1956
1962
|
}
|
|
1957
1963
|
: null;
|
|
1958
1964
|
const decisions = [];
|
|
@@ -1970,8 +1976,7 @@ async function build(opts) {
|
|
|
1970
1976
|
if (knowledgeRoot) {
|
|
1971
1977
|
try {
|
|
1972
1978
|
const raw = await node_fs_1.promises.readFile(path.join(knowledgeRoot, "Home.md"), "utf8");
|
|
1973
|
-
|
|
1974
|
-
home = await parseGeneric(raw, "Home.md", toRepoRelative(knowledgeRoot), repo.baseUrl, repo.branch, path.basename(path.resolve(knowledgeRoot)));
|
|
1979
|
+
home = await parseGeneric(raw, "Home.md", toRepoRelative(knowledgeRoot), repo.baseUrl, repo.branch);
|
|
1975
1980
|
// 自己記述の節を取り出し、**本文からは抜く**(最上部に出すので、残すと2度出る)。
|
|
1976
1981
|
// 旧い使い方の説明もここで落ちる(説明はビューアが持つようになった)
|
|
1977
1982
|
const grounding = await buildHomeGrounding(raw);
|
|
@@ -2000,7 +2005,7 @@ async function build(opts) {
|
|
|
2000
2005
|
const records = await loadGenericRecords(recordsDir, exclude, repo.baseUrl, repo.branch);
|
|
2001
2006
|
// 「人が確認した版から変わりました」の帯と、用語集の並び(#665 便 12b)。
|
|
2002
2007
|
// **帳簿を持たない案件では 1 件も付かず、並びもそのまま**(下の attachRederived)
|
|
2003
|
-
const ordered = attachRederived(records,
|
|
2008
|
+
const ordered = await attachRederived(records, recordsDir, rederive, opts.generatedAt);
|
|
2004
2009
|
// レコード0件でもタブは出す(空状態から「+AIで登録」できる導線になる)。
|
|
2005
2010
|
// records/ 自体が無いディレクトリ(その区画が未導入の案件)はタブにしない
|
|
2006
2011
|
if (records.length || (await isDirectory(recordsDir))) {
|
|
@@ -2037,7 +2042,15 @@ async function build(opts) {
|
|
|
2037
2042
|
`(浅い clone か履歴の窓の外。人か機械か見分けられないので帯は出しません): ` +
|
|
2038
2043
|
shas.slice(0, 3).map((s) => s.slice(0, 8)).join(", ") + (shas.length > 3 ? " ほか" : ""));
|
|
2039
2044
|
}
|
|
2040
|
-
|
|
2045
|
+
// 🔴 **印が静かに消えるもう 1 つの経路を 1 行残す。** 人が確認した版の中身を git から
|
|
2046
|
+
// 引けないと「説明文が変わったか」を言えないので、印は出さない(誤報より無言)。
|
|
2047
|
+
// 出さないと「印が出ない」の原因を「中身が変わっていない」と切り分けられない
|
|
2048
|
+
if (rederive && rederive.miss.unreadable.size) {
|
|
2049
|
+
const paths = [...rederive.miss.unreadable];
|
|
2050
|
+
console.warn(`gold-confirmation.json: 人が確認した版の中身を ${paths.length} 件引けませんでした` +
|
|
2051
|
+
`(浅い clone か、確認の後に改名・削除された。変わったかを言えないので印は出しません): ` +
|
|
2052
|
+
paths.slice(0, 3).join(", ") + (paths.length > 3 ? " ほか" : ""));
|
|
2053
|
+
}
|
|
2041
2054
|
const unmatched = [...PROPOSALS_BY_PATH.keys()].filter((k) => !PROPOSAL_PATHS_MATCHED.has(k));
|
|
2042
2055
|
if (unmatched.length) {
|
|
2043
2056
|
console.warn(`gold-proposals.json: レコードに突き合わない提案が ${unmatched.length} 件ありました(表示しません): ` +
|
|
@@ -2055,10 +2068,7 @@ async function build(opts) {
|
|
|
2055
2068
|
// git が無い・浅い clone・該当0件なら空で、履歴の節が出ないだけ(他の表示は壊れない)。
|
|
2056
2069
|
// 走るのは latestGeneratedPaths と同じ **リポジトリルート**(process.cwd())——
|
|
2057
2070
|
// Decisions ディレクトリで実行すると pathspec に一致せず常に空になる
|
|
2058
|
-
|
|
2059
|
-
// 全部一致したらエントリを丸ごと捨てるので、収集の側で伏せると**別々の ID が全部
|
|
2060
|
-
// 「(伏せた値)」になって一致し、本物の書き換えが履歴から黙って消える**(#681)
|
|
2061
|
-
const aiHistory = (0, person_redact_1.redactAiHistory)((0, repo_1.collectAiChangeHistory)(process.cwd()));
|
|
2071
|
+
const aiHistory = (0, repo_1.collectAiChangeHistory)(process.cwd());
|
|
2062
2072
|
// 案件アイコン(#348)。Cortex構成でなければ undefined(旧レイアウトには置き場が無い)
|
|
2063
2073
|
const projectIcon = await findProjectIcon(knowledgeRoot);
|
|
2064
2074
|
const site = {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 外の ID の「形」の判定(#686)。
|
|
4
|
+
*
|
|
5
|
+
* 🔴 **伏せるためではなく、`<種類>:<値>` の形の ID を見分けて画面で解釈するための判定。**
|
|
6
|
+
* 関係者の記録には `slack_user:U0…` のような外のサービスの ID が並ぶ。画面はこれを
|
|
7
|
+
* 「種類」と「値」に割って読ませたい(どのサービスの ID なのかが分からないと、人は
|
|
8
|
+
* 結び付きが正しいかを確かめられない)。そのための見分けをここに置く。
|
|
9
|
+
*
|
|
10
|
+
* #681 で入れた伏せの実装(`person-redact.ts`)は #686 で撤去した。**形の判定だけを残して
|
|
11
|
+
* ある**——判定そのものは伏せとは関係なく要るもので、書き直すと同じ取りこぼしを踏む。
|
|
12
|
+
*
|
|
13
|
+
* ## 種類を列挙しない
|
|
14
|
+
*
|
|
15
|
+
* 外の ID は `<種類>:<値>` の形で書かれる(engine `docs/observations.md` の `<id_kind>:<id>`)。
|
|
16
|
+
* **種類を列挙すると、engine が新しい種類を足した晩に見分けられなくなる**ので、形だけで
|
|
17
|
+
* 判定する。種類の大文字小文字は問わない。
|
|
18
|
+
*
|
|
19
|
+
* 逆に、**このリポジトリの内側の呼び名は外の ID と見なさない**(`INSIDE_KINDS`)。
|
|
20
|
+
* `member:山田太郎` は名前そのもので、`minute:` / `material:` / `design:` は
|
|
21
|
+
* つながりの図が正本の URL を引く行き先(`build.ts` の `classifyTarget`)。
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.INSIDE_KIND_LIST = exports.TYPED_ID_WHOLE_SRC = exports.FM_LINKED_IDS = void 0;
|
|
25
|
+
exports.normalizeSeparators = normalizeSeparators;
|
|
26
|
+
exports.isExternalId = isExternalId;
|
|
27
|
+
exports.splitTypedId = splitTypedId;
|
|
28
|
+
exports.isMemberRecord = isMemberRecord;
|
|
29
|
+
exports.isMemberRecordPath = isMemberRecordPath;
|
|
30
|
+
/**
|
|
31
|
+
* このリポジトリの内側の呼び名。外の ID と見なさない。
|
|
32
|
+
*
|
|
33
|
+
* 前半は記録の id の種類(`build.ts` の `GOLD_TYPES` と同じ)、後半は Silver の参照
|
|
34
|
+
* (`build.ts` の `classifyTarget` が正本の URL に解決する 3 つ)。
|
|
35
|
+
*/
|
|
36
|
+
const INSIDE_KINDS = new Set([
|
|
37
|
+
"decision", "term", "member", "rule", "report", "overview",
|
|
38
|
+
"minute", "material", "design",
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* `<種類>:<値>` 1 つぶん。
|
|
42
|
+
*
|
|
43
|
+
* 🔴 **コロンの直後に空白があるものは当てない。** `[a-z_]+:` + 1 文字以上は YAML の
|
|
44
|
+
* `キー: 値` そのものの形なので、空白を許すと `title: 山田 太郎` のような行まで
|
|
45
|
+
* 「外の ID」と見なしてしまう。
|
|
46
|
+
*/
|
|
47
|
+
const TYPED_ID_SRC = "([A-Za-z][A-Za-z0-9_]*):([^\\s,\"'\\[\\]、。,))]+)";
|
|
48
|
+
/** メールアドレスの形(`email:` を付けずに書かれたもの) */
|
|
49
|
+
const EMAIL_SRC = "[^\\s,\"'<>\\[\\]、。,))]+@[^\\s,\"'<>\\[\\]、。,))]+";
|
|
50
|
+
const TYPED_ID_WHOLE = new RegExp("^" + TYPED_ID_SRC + "$");
|
|
51
|
+
const EMAIL_WHOLE = new RegExp("^" + EMAIL_SRC + "$");
|
|
52
|
+
/**
|
|
53
|
+
* 全角コロンを半角に寄せる。人が名簿を手で書く欄なので、日本語入力のまま
|
|
54
|
+
* `slack_user:…` と区切りだけ全角になった行を取りこぼさない。
|
|
55
|
+
*/
|
|
56
|
+
function normalizeSeparators(s) {
|
|
57
|
+
return s.replace(/:/g, ":");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* その文字列ぜんたいが外の ID か(`aliases` の 1 要素の判定)。
|
|
61
|
+
*
|
|
62
|
+
* 判定に使うだけで中身を書き戻さないので、ここでは**全角を丸ごと半角に寄せて**から当てる
|
|
63
|
+
* (`slack_user:U0X` のように、種類まで全角で書かれた行を取りこぼさない)。
|
|
64
|
+
*/
|
|
65
|
+
function isExternalId(v) {
|
|
66
|
+
if (typeof v !== "string")
|
|
67
|
+
return false;
|
|
68
|
+
const s = v.normalize("NFKC").trim();
|
|
69
|
+
if (s === "")
|
|
70
|
+
return false;
|
|
71
|
+
// 外のサービスの呼び名を `@名前` の形で書いたもの
|
|
72
|
+
if (/^@\S+$/.test(s))
|
|
73
|
+
return true;
|
|
74
|
+
// ドットの無い社内アドレス(`taro@example`)も外の ID に入れる
|
|
75
|
+
if (EMAIL_WHOLE.test(s))
|
|
76
|
+
return true;
|
|
77
|
+
const m = TYPED_ID_WHOLE.exec(s);
|
|
78
|
+
return m != null && !INSIDE_KINDS.has(m[1].toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* `<種類>:<値>` を種類と値に割る。画面が「どのサービスの ID か」を出すための口。
|
|
82
|
+
*
|
|
83
|
+
* 内側の呼び名(`member:` / `minute:` 等)と、形に当たらない値は `null` を返す
|
|
84
|
+
* ——外の ID として解釈してはいけないものを、呼び出し側が取り違えないようにする。
|
|
85
|
+
* 全角で書かれていても割れるよう、`isExternalId` と同じく先に半角へ寄せてから当てる
|
|
86
|
+
* (返す種類・値も寄せたあとの字面)。
|
|
87
|
+
*/
|
|
88
|
+
function splitTypedId(v) {
|
|
89
|
+
if (typeof v !== "string")
|
|
90
|
+
return null;
|
|
91
|
+
const m = TYPED_ID_WHOLE.exec(normalizeSeparators(v.normalize("NFKC").trim()));
|
|
92
|
+
if (!m)
|
|
93
|
+
return null;
|
|
94
|
+
if (INSIDE_KINDS.has(m[1].toLowerCase()))
|
|
95
|
+
return null;
|
|
96
|
+
return { kind: m[1], value: m[2] };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* 関係者の記録で「つながっている ID」を載せるフロントマターの欄の名前(#686)。
|
|
100
|
+
*
|
|
101
|
+
* 🔴 **この綴りを他所に書かない。** 欄名は engine 側でまだ確定していない
|
|
102
|
+
*(`identities` か `linked_ids` か未裁定)。読む側(画面の「つながっている ID」の節と
|
|
103
|
+
* レコード情報のパネル)と、書く側(AIで編集の下書き)が別々に綴りを持つと、
|
|
104
|
+
* engine が改名した日に**片方だけが追随して静かに食い違う**。定数はここ 1 本にして、
|
|
105
|
+
* 両方がこれを引く。
|
|
106
|
+
*
|
|
107
|
+
* 🔴 画面の中では `identities` と呼ばない(`linkedIds` / `.linked-ids` / `.li-*`)。
|
|
108
|
+
* `identities` という語は、このリポジトリでは既に `GoldCommitIdentity`
|
|
109
|
+
*(帯のコミットの筆者)の意味で使っている——同じ語を 2 つの意味で使わない。
|
|
110
|
+
*/
|
|
111
|
+
exports.FM_LINKED_IDS = "identities";
|
|
112
|
+
/**
|
|
113
|
+
* 関係者(Members)の記録か。
|
|
114
|
+
*
|
|
115
|
+
* 🔴 **パスの文字列だけで判定しない。** `toRepoRelative` は `process.cwd()` 相対で、
|
|
116
|
+
* cwd がリポジトリルートでないと `records` に倒れる——判定が外れると**関係者の記録なのに
|
|
117
|
+
* 関係者として扱われない**側に倒れる。だから cwd に依存しない印を 3 つ見る。
|
|
118
|
+
*
|
|
119
|
+
* #681 の伏せの実装(`person-redact.ts`)は #686 で撤去したが、**この判定だけは残す**
|
|
120
|
+
* ——伏せとは関係なく「関係者の記録か」は要るもので、書き直すと同じ取りこぼしを踏む。
|
|
121
|
+
*
|
|
122
|
+
* @param type frontmatter の `type`
|
|
123
|
+
* @param id frontmatter の `id`(engine は `member:{氏名}` を必須にしている)
|
|
124
|
+
* @param sectionDir 区画のディレクトリ名(`records` の親。絶対パスから取る)
|
|
125
|
+
*/
|
|
126
|
+
function isMemberRecord(type, id, sectionDir) {
|
|
127
|
+
if (typeof type === "string" && type.trim() === "member")
|
|
128
|
+
return true;
|
|
129
|
+
if (typeof id === "string" && id.startsWith("member:"))
|
|
130
|
+
return true;
|
|
131
|
+
return sectionDir === "Members";
|
|
132
|
+
}
|
|
133
|
+
/** そのリポジトリ相対パスが関係者の記録の置き場か(git のパスは cwd に依存しない) */
|
|
134
|
+
function isMemberRecordPath(repoPath) {
|
|
135
|
+
return /(^|\/)Members\/records\/[^/]+\.md$/.test(repoPath);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `<種類>:<値>` 1 つぶんの形(文字列ぜんたい)の**正規表現の素**(#686)。
|
|
139
|
+
*
|
|
140
|
+
* 🔴 **画面の埋め込み JS に同じ形を 2 度書かないために出す。** 名寄せの提案を
|
|
141
|
+
* 「同じ人ですか」の問いに変えてよいかの門は、この形だけで見分ける
|
|
142
|
+
*(`isExternalId` の「`@名前`」「素のメール」の枝は門に使わない——`aliases` には
|
|
143
|
+
* ハンドル名が普通に入るので、`@yamada` が門を通ると、engine が永久に読まない行を
|
|
144
|
+
* 記録に書くことになる)。綴りを写すと、engine の形が変わった日に画面だけが取り残される。
|
|
145
|
+
*/
|
|
146
|
+
exports.TYPED_ID_WHOLE_SRC = "^" + TYPED_ID_SRC + "$";
|
|
147
|
+
/** 内側の呼び名の一覧(同上。埋め込み JS が `splitTypedId` と同じ除外を持つため) */
|
|
148
|
+
exports.INSIDE_KIND_LIST = [...INSIDE_KINDS];
|
package/dist/rederivation.js
CHANGED
|
@@ -9,6 +9,10 @@ exports.parseLedger = parseLedger;
|
|
|
9
9
|
exports.readRederivationLedgers = readRederivationLedgers;
|
|
10
10
|
exports.parseInjectedNow = parseInjectedNow;
|
|
11
11
|
exports.activityScore = activityScore;
|
|
12
|
+
exports.comparableContent = comparableContent;
|
|
13
|
+
exports.sameRederivedContent = sameRederivedContent;
|
|
14
|
+
exports.contentShaOf = contentShaOf;
|
|
15
|
+
exports.rederivedContentKey = rederivedContentKey;
|
|
12
16
|
exports.rederivedBandOf = rederivedBandOf;
|
|
13
17
|
exports.orderByActivity = orderByActivity;
|
|
14
18
|
/**
|
|
@@ -34,8 +38,10 @@ exports.orderByActivity = orderByActivity;
|
|
|
34
38
|
* 引数で注入する(`generatedAt`)——`new Date()` を中で読むと、固定日付のテストが
|
|
35
39
|
* 実時間と比べられて**ある日から赤くなる**(作業規約「日付爆弾」)。
|
|
36
40
|
*/
|
|
41
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
37
42
|
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
38
43
|
const node_path_1 = __importDefault(require("node:path"));
|
|
44
|
+
const gray_matter_1 = __importDefault(require("gray-matter"));
|
|
39
45
|
const commit_classify_1 = require("./commit-classify");
|
|
40
46
|
/** 帳簿の版(違ったら空として扱う・engine `LEDGER_VERSION`) */
|
|
41
47
|
exports.LEDGER_VERSION = 1;
|
|
@@ -135,12 +141,15 @@ async function readRederivationLedgers(repoRoot) {
|
|
|
135
141
|
const changedBy = Array.isArray(row?.changed_by)
|
|
136
142
|
? row.changed_by.map((s) => String(s)).filter((s) => s !== "")
|
|
137
143
|
: [];
|
|
144
|
+
// 中身の指紋は**文字列のときだけ**持つ(engine がまだ書かないので、いまは常に無い)
|
|
145
|
+
const contentSha = typeof confirmed.content_sha === "string" ? confirmed.content_sha : "";
|
|
138
146
|
out.confirmation.set(p, {
|
|
139
147
|
at: confirmed.at != null ? String(confirmed.at) : "",
|
|
140
148
|
by: confirmed.by != null ? String(confirmed.by) : "",
|
|
141
149
|
verified: confirmed.verified === true,
|
|
142
150
|
commit: commit,
|
|
143
151
|
changedBy: changedBy,
|
|
152
|
+
...(contentSha ? { contentSha: contentSha } : {}),
|
|
144
153
|
});
|
|
145
154
|
}
|
|
146
155
|
}
|
|
@@ -250,6 +259,84 @@ function activityScore(entry, nowIso) {
|
|
|
250
259
|
// 未来の日付(時計のずれ・手で書いた帳簿)は上の下限で潰れるので、ここは最後の網
|
|
251
260
|
return Number.isFinite(score) ? score : null;
|
|
252
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* 比較のための正規化。**末尾の改行・行末の空白を落とし、NFC にそろえる。**
|
|
264
|
+
*
|
|
265
|
+
* 改行コード(CRLF / CR)も LF にそろえる——「説明文が変わりましたか」の問いに対して、
|
|
266
|
+
* 改行コードだけの差を「変わった」と答えるのは嘘に近い。
|
|
267
|
+
*/
|
|
268
|
+
function normalizeForCompare(text) {
|
|
269
|
+
return String(text ?? "")
|
|
270
|
+
.replace(/\r\n?/g, "\n")
|
|
271
|
+
.split("\n")
|
|
272
|
+
.map((line) => line.replace(/[ \t\u3000]+$/, ""))
|
|
273
|
+
.join("\n")
|
|
274
|
+
.replace(/\n+$/, "")
|
|
275
|
+
.normalize("NFC");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* レコードの生テキストから、**人が読むところだけ**を取り出す(印の判定の材料)。
|
|
279
|
+
*
|
|
280
|
+
* 🔴 **`description` のフォールバックを使わない**(用語集の `synonyms` の補足行・旧 `summary`)。
|
|
281
|
+
* 画面の要約はそれらを継ぎ足して作るが、この印が言うのは「**説明文が変わった**」なので、
|
|
282
|
+
* `description` の欄そのものだけを見る。継ぎ足すと「別名を 1 つ足しただけ」で印が立つ。
|
|
283
|
+
*
|
|
284
|
+
* 🔴 **frontmatter の他の欄は 1 つも見ない。** 今回の誤報(関係者 11 人に出た回)の原因は
|
|
285
|
+
* 使われない 1 行(`side: cm`)が移行で消えたことだった。`identities` のような欄が
|
|
286
|
+
* 足された晩に、説明文が 1 文字も変わっていないのに印を立てない。
|
|
287
|
+
*/
|
|
288
|
+
function comparableContent(raw) {
|
|
289
|
+
let data = {};
|
|
290
|
+
let content = String(raw ?? "");
|
|
291
|
+
try {
|
|
292
|
+
const parsed = (0, gray_matter_1.default)(String(raw ?? ""));
|
|
293
|
+
data = (parsed.data ?? {});
|
|
294
|
+
content = parsed.content;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// frontmatter が壊れているレコードは**本文として丸ごと比べる**(読めないことを
|
|
298
|
+
// 「変わった」の側に倒さない。壊れた形のまま両方同じなら印は立たない)
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
description: normalizeForCompare(String(data.description ?? "")),
|
|
302
|
+
body: normalizeForCompare(content),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** 2 つの版で「説明文と本文」が同じか(frontmatter の他の欄の差は見ない) */
|
|
306
|
+
function sameRederivedContent(a, b) {
|
|
307
|
+
const x = comparableContent(a);
|
|
308
|
+
const y = comparableContent(b);
|
|
309
|
+
return x.description === y.description && x.body === y.body;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 「説明文と本文」の指紋。**帳簿の `confirmed.content_sha` と突き合わせる綴り**。
|
|
313
|
+
*
|
|
314
|
+
* 🔴 engine がこの欄を書き始めるまで、この値と比べる相手は存在しない。**一致したときだけ
|
|
315
|
+
* 「変わっていない」と言い、食い違ったら git へ落とす**(`attachRederived`)——engine が
|
|
316
|
+
* 別の綴りで指紋を作っていた場合に、全レコードへ誤報が出るのを避けるため。
|
|
317
|
+
*/
|
|
318
|
+
function contentShaOf(raw) {
|
|
319
|
+
const c = comparableContent(raw);
|
|
320
|
+
return node_crypto_1.default.createHash("sha256").update(c.description + "\u0000" + c.body, "utf8").digest("hex");
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* そのレコードが印の**候補**なら、人が確認した版を git から引くための key
|
|
324
|
+
* (`<基準コミット>:<path>`)。候補でなければ null。
|
|
325
|
+
*
|
|
326
|
+
* 🔴 **決定表の行 0〜3 と同じ門**(`rederivedBandOf` の手前の安い行だけ)。ここで候補を
|
|
327
|
+
* 絞ってから `readBlobsAt` に**まとめて 1 回**渡す——全レコードぶんの key を作ると、
|
|
328
|
+
* 用語が 1400 件ある案件で毎晩 1400 個の blob を読むことになる。
|
|
329
|
+
*/
|
|
330
|
+
function rederivedContentKey(recordPath, status, ledgers) {
|
|
331
|
+
if (!isConfirmedStatus(status))
|
|
332
|
+
return null;
|
|
333
|
+
if (!BAND_SECTION_RE.test(recordPath))
|
|
334
|
+
return null;
|
|
335
|
+
const conf = ledgers.confirmation.get(recordPath);
|
|
336
|
+
if (!conf || !conf.commit || !conf.changedBy.length)
|
|
337
|
+
return null;
|
|
338
|
+
return `${conf.commit}:${recordPath}`;
|
|
339
|
+
}
|
|
253
340
|
/**
|
|
254
341
|
* そのレコードに帯を立てるか(#665 の**決定表**・上から順に評価し、当たった行で終わる)。
|
|
255
342
|
*
|
|
@@ -265,15 +352,24 @@ function activityScore(entry, nowIso) {
|
|
|
265
352
|
* | 4c | 分類が「人」または「不明」 | 落とす |
|
|
266
353
|
* | 4d | **どちらかの綴りで引っ越し** | 落とす |
|
|
267
354
|
* | 4e | author 日付が確認日**以前**(epoch 比較) | 落とす |
|
|
268
|
-
* | 5 | 残りのうち最も新しい author 日付を「書き直した日」にする |
|
|
355
|
+
* | 5 | 残りのうち最も新しい author 日付を「書き直した日」にする | 候補になる |
|
|
356
|
+
* | **8** | **人が確認した版から「説明文(`description`)か本文」が変わったか**(`verdict`) | **`changed` のときだけ立てる** |
|
|
269
357
|
* | 6 | 根拠は、帳簿の行の `commit` が `changed_by` に入っているときだけ出す | 出す/出さない |
|
|
270
358
|
* | 7 | 基準コミットが削除のコミットなら「戻す」を出さない(`baseDeleted`) | — |
|
|
271
359
|
*
|
|
360
|
+
* 🔴 **行 8 がこの機能の本丸**(2026-09-20 の誤報)。前はここが無く、「確認の基準コミットより
|
|
361
|
+
* 後に機械のコミットがその path に 1 件でもあるか」だけで立てていた——**ファイルの中身を
|
|
362
|
+
* 1 バイトも見ていなかった**ので、移行でフロントマターの使わない 1 行(`side: cm`)が
|
|
363
|
+
* 消えただけの回にも、関係者 11 人の画面に「変わりました」が出た。
|
|
364
|
+
*
|
|
272
365
|
* @param status その記録の `rstatus`(`String()` 済み)。**2 番目の必須引数**にしてある——
|
|
273
366
|
* 末尾の任意引数にすると、渡し忘れが「行 0 の門が静かに開く」になる(設計レビュー 🔴3)
|
|
367
|
+
* @param verdict 行 8 の答えを引く関数(`(recordPath, baseCommit) => ContentVerdict`)。
|
|
368
|
+
* 🔴 **必須引数**にしてある——任意にすると、渡し忘れが「中身を見ない昔の判定」に静かに戻る。
|
|
369
|
+
* `changed` 以外(`same` / `unknown`)は**立てない**
|
|
274
370
|
* @param miss 立たなかった理由を数える器(呼び出し側で 1 つ作って全レコードで共有する)
|
|
275
371
|
*/
|
|
276
|
-
function rederivedBandOf(recordPath, status, ledgers, identities, miss) {
|
|
372
|
+
function rederivedBandOf(recordPath, status, ledgers, identities, verdict, miss) {
|
|
277
373
|
// 0. 「AI生成・未確認」の札が出る記録には帯を重ねない(#667)。**綴りは 1 か所**
|
|
278
374
|
//(ここで `status === "draft"` と直書きすると、判定が 2 つに増えて一致が崩れる)
|
|
279
375
|
if (!isConfirmedStatus(status))
|
|
@@ -336,6 +432,12 @@ function rederivedBandOf(recordPath, status, ledgers, identities, miss) {
|
|
|
336
432
|
// いちばん重い壊れ方なので、既定(立てない側)に倒す。engine が次の夜に基準点を動かす
|
|
337
433
|
if (newestHuman > newest.ms)
|
|
338
434
|
return null;
|
|
435
|
+
// 8. 🔴 **中身が変わったか。** ここまでは「機械がこの path を触ったか」しか見ていない。
|
|
436
|
+
// 触っただけ(フロントマターの使わない欄が移行で消えた・`identities` の欄が足された)で
|
|
437
|
+
// 印を立てると、説明文が 1 文字も変わっていない回に「変わりました」が出る(2026-09-20 の誤報)。
|
|
438
|
+
// 引けないとき(`unknown`)も立てない——誤報より無言
|
|
439
|
+
if (verdict(recordPath, conf.commit) !== "changed")
|
|
440
|
+
return null;
|
|
339
441
|
// 6. 根拠の件数は「この帯のコミットの記録」から出す(別の夜の数字を並べない)
|
|
340
442
|
const red = ledgers.rederivation.get(recordPath);
|
|
341
443
|
const evidence = red && red.evidence != null && red.commit && conf.changedBy.includes(red.commit)
|
|
@@ -360,23 +462,39 @@ function rederivedBandOf(recordPath, status, ledgers, identities, miss) {
|
|
|
360
462
|
};
|
|
361
463
|
}
|
|
362
464
|
/**
|
|
363
|
-
* 用語集の並び(#432 裁定 5-5
|
|
465
|
+
* 用語集の並び(#432 裁定 5-5 → #690 で群分けを撤廃)。**活性度の降順だけで決める。**
|
|
466
|
+
*
|
|
467
|
+
* 🔴 **確認済みかどうかは並びに使わない**(便 #690・オーナー決定 2026-09-21 逐語
|
|
468
|
+
* 「触れられた回数とか、携わってる人、携わってる回数が多い人が上に出てくる方がいい。
|
|
469
|
+
* **アクティブ、ドラフト関係なく**」)。engine の関係者の並び(`scripts/engagement/tiers.mjs`
|
|
470
|
+
* の `order`・便 #543)に当てたのと同じ形で、**同じ画面の中で並び方を揃える**。
|
|
471
|
+
*
|
|
472
|
+
* 前はここで「確認済みが先頭群」を先に見ていたので、**群の境目が活性度より強く**、
|
|
473
|
+
* ほとんど触られていない確認済みの語が先頭に並び、**いま出てきたばかりの下書きが下に沈んだ**。
|
|
474
|
+
* 理由は 2 つ:
|
|
475
|
+
* - 「確認済みかどうか」は**札で分かる**(`render.ts` の「AI生成・未確認」)ので、
|
|
476
|
+
* 並びで二重に表現する必要がない。下書きが上に来るのは「**新しく出てきた言葉**」なので、
|
|
477
|
+
* むしろ見たいもの
|
|
478
|
+
* - 🔴 **この並びは「索引の上から N 件だけを AI に読ませる」構想の土台**(engine #544)。
|
|
479
|
+
* 「上に来るものが重要」でなければ、その仕組みが成り立たない
|
|
364
480
|
*
|
|
365
|
-
* 🔴
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* の用語集で 25 件)。帳簿の役目は「どの版に戻すか」だけになった。
|
|
481
|
+
* 🔴 **`isConfirmedStatus` は消さない**——このファイルの**印の門(`rederivedContentKey`)と
|
|
482
|
+
* 帯の門(`rederivedBandOf`)**が使う。`render.ts` の札は `rec.rstatus === "draft"` の
|
|
483
|
+
* **別実装**で、2 つが一致していることは `rederived-mark-ui.test.mjs` の `[札との一致]` が見る。
|
|
484
|
+
* ここで変えるのは**並びだけ**で、札・帯・印・プロンプトに渡る材料は 1 つも変えていない。
|
|
370
485
|
*
|
|
371
486
|
* - 活性度を持つレコードが 1 件も無い区画は **1 バイトも触らない**(帳簿が無い案件は従来順)
|
|
372
|
-
* -
|
|
487
|
+
* - 活性度を知らないレコードは**末尾**へ、**元の並びのまま**(安定)
|
|
488
|
+
* - 同じ活性度なら元の並び(日付降順 → タイトル昇順がそのまま残る)
|
|
373
489
|
* - 用語集だけ(メンバーの並びは便 14・Decisions と Rules は対象外)
|
|
374
490
|
*
|
|
375
|
-
* ℹ️ `ledgers`
|
|
376
|
-
*
|
|
491
|
+
* ℹ️ `ledgers` と `statusOf` は**受け取り続ける**。`ledgers` は活性度のために要る。
|
|
492
|
+
* `statusOf` は並びに使わなくなったが、**引数から落とすと「群分けを足し戻す」退行が
|
|
493
|
+
* コンパイルで止まる代わりに、変異検証でそれを実演できなくなる**(`ledgers` と同じ流儀)。
|
|
494
|
+
* 番人(`rederivation-ledgers.test.mjs` の `[並び]`)はこの 1 行の足し戻しで赤くなる。
|
|
377
495
|
*
|
|
378
496
|
* @param records 区画のレコード(`repoPathOf` で引けるパスを持つもの。並びは変えずに新しい配列を返す)
|
|
379
|
-
* @param statusOf そのレコードの `rstatus`(`String()`
|
|
497
|
+
* @param statusOf そのレコードの `rstatus`(`String()` 済み)を引く関数。**並びには使わない**(上の ℹ️)
|
|
380
498
|
*/
|
|
381
499
|
function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
|
|
382
500
|
if (!records.length)
|
|
@@ -388,7 +506,6 @@ function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
|
|
|
388
506
|
return {
|
|
389
507
|
r: r,
|
|
390
508
|
i: i,
|
|
391
|
-
confirmed: inSection && isConfirmedStatus(statusOf(r)),
|
|
392
509
|
score: inSection ? activityScore(ledgers.activity.get(p), nowIso) : null,
|
|
393
510
|
};
|
|
394
511
|
});
|
|
@@ -397,15 +514,12 @@ function orderByActivity(records, pathOf, statusOf, ledgers, nowIso) {
|
|
|
397
514
|
return scored
|
|
398
515
|
.slice()
|
|
399
516
|
.sort((a, b) => {
|
|
400
|
-
// 1.
|
|
401
|
-
if (a.confirmed !== b.confirmed)
|
|
402
|
-
return a.confirmed ? -1 : 1;
|
|
403
|
-
// 2. 群の中は活性度の降順。知らないものは群の末尾
|
|
517
|
+
// 1. 活性度の降順だけ(便 #690・確認済みかどうかでは分けない)。知らないものは末尾
|
|
404
518
|
if (a.score != null && b.score != null && a.score !== b.score)
|
|
405
519
|
return b.score - a.score;
|
|
406
520
|
if ((a.score == null) !== (b.score == null))
|
|
407
521
|
return a.score == null ? 1 : -1;
|
|
408
|
-
//
|
|
522
|
+
// 2. 同点は元の並び(安定・日付降順 → タイトル昇順がそのまま残る)
|
|
409
523
|
return a.i - b.i;
|
|
410
524
|
})
|
|
411
525
|
.map((s) => s.r);
|