@takagaki/cortex-decisions-viewer 0.4.54 → 0.4.55

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.
Files changed (2) hide show
  1. package/dist/build.js +135 -43
  2. package/package.json +1 -1
package/dist/build.js CHANGED
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.readFleetSources = readFleetSources;
40
40
  exports.build = build;
41
+ exports.createRefResolver = createRefResolver;
41
42
  const node_fs_1 = require("node:fs");
42
43
  const path = __importStar(require("node:path"));
43
44
  const gray_matter_1 = __importDefault(require("gray-matter"));
@@ -351,10 +352,57 @@ async function walkMd(dirAbs) {
351
352
  * - 課題キー(例: PJ_CORTEX-13)→ 同期mdに記載された Backlog Issue Link(実Backlog URL)
352
353
  * - minute:{定例名}:{YYYYMMDD} → 議事録ファイルのGitHub blobリンク
353
354
  */
355
+ /**
356
+ * マーカーファイルの在り処からディレクトリ名を求める(`cortex-engine/scripts/fleet-status.mjs`
357
+ * の `findDirByMarker` と同じ考え方)。
358
+ *
359
+ * **ディレクトリ名をハードコードしない。** `customize-tooling` で改名している案件が実在する
360
+ * (ある案件は `課題管理/`→`Backlog/`・`会議/`→`MTG/`・`デザイン/`→`Figma/`)。
361
+ * 決め打ちだと、その案件では課題キーもFigmaも**永久に解決できない**。
362
+ */
363
+ async function findDirByMarker(repoRoot, marker, fallback) {
364
+ try {
365
+ for (const d of await node_fs_1.promises.readdir(repoRoot, { withFileTypes: true })) {
366
+ if (!d.isDirectory() || d.name === "node_modules" || d.name.startsWith("."))
367
+ continue;
368
+ try {
369
+ await node_fs_1.promises.stat(path.join(repoRoot, d.name, marker));
370
+ return d.name;
371
+ }
372
+ catch {
373
+ // 直下に無ければ1階層下も見る(backlog-settings.json は issues/ の下に置かれる)
374
+ }
375
+ try {
376
+ for (const sub of await node_fs_1.promises.readdir(path.join(repoRoot, d.name), { withFileTypes: true })) {
377
+ if (!sub.isDirectory())
378
+ continue;
379
+ try {
380
+ await node_fs_1.promises.stat(path.join(repoRoot, d.name, sub.name, marker));
381
+ return d.name;
382
+ }
383
+ catch {
384
+ // 次へ
385
+ }
386
+ }
387
+ }
388
+ catch {
389
+ // 読めないディレクトリは飛ばす
390
+ }
391
+ }
392
+ }
393
+ catch {
394
+ // repoRoot が読めなければ既定名で進む
395
+ }
396
+ return fallback;
397
+ }
354
398
  async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
355
399
  const map = new Map();
356
- // 課題キー Backlog URL(課題管理/issues/ の同期mdの基本情報から抽出)
357
- for (const file of await walkMd(path.join(repoRoot, "課題管理", "issues"))) {
400
+ const issuesDir = await findDirByMarker(repoRoot, "backlog-settings.json", "課題管理");
401
+ const designDir = await findDirByMarker(repoRoot, "figma.json", "デザイン");
402
+ const meetingDir = await findDirByMarker(repoRoot, "ingest-config.json", "会議");
403
+ const materialsDir = await findDirByMarker(repoRoot, "materials-config.json", "共有資料");
404
+ // 課題キー → Backlog URL({課題管理}/issues/ の同期mdの基本情報から抽出)
405
+ for (const file of await walkMd(path.join(repoRoot, issuesDir, "issues"))) {
358
406
  try {
359
407
  const head = (await node_fs_1.promises.readFile(file, "utf8")).slice(0, 4000);
360
408
  const keyMatch = head.match(/^- 課題キー: (\S+)$/m);
@@ -367,7 +415,7 @@ async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
367
415
  }
368
416
  }
369
417
  // material:{slug} → 共有資料の正本(Drive等のsource URL)。無ければ変換済みmdのGitHubリンク
370
- for (const file of await walkMd(path.join(repoRoot, "共有資料"))) {
418
+ for (const file of await walkMd(path.join(repoRoot, materialsDir))) {
371
419
  if (path.basename(file).toLowerCase() === "link.md")
372
420
  continue;
373
421
  try {
@@ -397,22 +445,32 @@ async function buildTargetUrlMap(repoRoot, repoBaseUrl, branch) {
397
445
  // 読めないファイルはスキップ
398
446
  }
399
447
  }
400
- // design:{fileKey}:{nodeId} → Figmaディープリンク(インベントリのfrontmatterから)
401
- for (const file of await walkMd(path.join(repoRoot, "デザイン", "inventory"))) {
448
+ // design:{fileKey}:{nodeId} → Figmaディープリンク
449
+ //
450
+ // **frontmatter からは取れない。** インベントリは frontmatter を持たない
451
+ // (frontmatter を持つのは Gold層だけ=オントロジー規約)。参照IDもFigmaのURLも**本文**にある:
452
+ // - 参照ID: `design:xxx:103:1836`
453
+ // - [Figmaで開く](https://www.figma.com/design/...)
454
+ // 以前は `data.type === "design"` を見ていたので、この解決は**一度も効いていなかった**。
455
+ for (const file of await walkMd(path.join(repoRoot, designDir, "inventory"))) {
402
456
  try {
403
- const { data } = (0, gray_matter_1.default)(await node_fs_1.promises.readFile(file, "utf8"));
404
- if (data.type === "design" && data.id && data.source) {
405
- map.set(String(data.id), String(data.source));
406
- }
457
+ const body = await node_fs_1.promises.readFile(file, "utf8");
458
+ const id = body.match(/^- 参照ID: `(design:[^`]+)`\s*$/m);
459
+ const url = body.match(/^- \[Figmaで開く\]\((https?:[^)]+)\)\s*$/m);
460
+ if (id && url)
461
+ map.set(id[1], url[1]);
407
462
  }
408
463
  catch {
409
464
  // 読めないファイルはスキップ
410
465
  }
411
466
  }
412
467
  // minute:{定例名}:{YYYYMMDD} → 議事録ファイルのGitHubリンク
413
- // パス規約: ミーティング/{フェーズ}/{定例名}/{YYYYMMDD}/*minutes*.md
468
+ // パス規約: {会議}/{フェーズ}/{定例名}/{YYYYMMDD}/*minutes*.md
469
+ //
470
+ // **以前は存在しない `ミーティング/` を見ていた**(艦隊は全案件 `会議/`。改名案件は `MTG/`)。
471
+ // そのため minute: の解決は一度も効いていなかった。
414
472
  if (repoBaseUrl) {
415
- for (const file of await walkMd(path.join(repoRoot, "ミーティング"))) {
473
+ for (const file of await walkMd(path.join(repoRoot, meetingDir))) {
416
474
  const name = path.basename(file).toLowerCase();
417
475
  if (!name.includes("minutes"))
418
476
  continue;
@@ -567,7 +625,7 @@ async function scanContent(repoRoot, goldDirName, repoBaseUrl, branch) {
567
625
  * 参照先がリポジトリ内のレコードでないIDは、層を推定したファントムノードとして表示する
568
626
  * (minute:/material:→Silver、課題キー・外部URL→Bronze、Goldの未解決ID→phantom)。
569
627
  */
570
- function buildGraph(decisions, home, sections, content, targetUrls, repoBaseUrl, branch) {
628
+ function buildGraph(decisions, home, sections, content, targetUrls, repoBaseUrl, branch, refs) {
571
629
  const nodes = new Map();
572
630
  const edges = [];
573
631
  const addNode = (node) => {
@@ -627,7 +685,12 @@ function buildGraph(decisions, home, sections, content, targetUrls, repoBaseUrl,
627
685
  else if (target.includes("/") || target.toLowerCase().endsWith(".md")) {
628
686
  group = "silver"; // リポジトリ内ファイルへのパス参照 → GitHubのコピーへ飛べるようにする
629
687
  label = target.split("/").pop() ?? target;
630
- const blobUrl = repoBaseUrl ? `${repoBaseUrl}/blob/${branch}/${encodePath(target)}` : undefined;
688
+ // **実在するものだけリンクにする。** 詳細画面(resolveReference)と同じ判定を使う。
689
+ // ここだけ無条件に blob URL を作っていたため、同じ参照が「詳細ではテキスト・
690
+ // グラフでは404リンク」に分かれていた(グラフのノードは window.open される)。
691
+ const blobUrl = repoBaseUrl && refs.exists(target)
692
+ ? `${repoBaseUrl}/blob/${branch}/${encodePath(target)}`
693
+ : undefined;
631
694
  return addNode({ id: target, label, group, url: blobUrl });
632
695
  }
633
696
  return addNode({ id: target, label, group });
@@ -948,15 +1011,23 @@ async function build(opts) {
948
1011
  // (Decisionsディレクトリで実行すると `-- Cortex/` に一致せず常に空になる)。
949
1012
  // 浅いclone・git無しの環境では空集合が返り、「最新」ラベルが出ないだけで他の表示は壊れない。
950
1013
  LATEST_PATHS = (0, repo_1.latestGeneratedPaths)(process.cwd());
1014
+ // Cortex構成なら、ホームと「records/ を持つディレクトリ」をすべて読み込む(旧構成では空)
1015
+ // ディレクトリ名はハードコードしない: 案件が新しいレコード種別のディレクトリを足せば、そのままタブとして表示される
1016
+ const knowledgeRoot = resolveKnowledgeRoot(dirAbs);
1017
+ // **参照の解決器は decisions のパースより前に作る。** parseDecision が references を
1018
+ // 解決するので、あとから作ると空の表・空の実在判定が渡り、**実在するパスまで
1019
+ // 「リンクにしない」に倒れて全滅する**(以前は順序が逆だった)。
1020
+ const repoRoot = knowledgeRoot ? path.dirname(knowledgeRoot) : path.dirname(dirAbs);
1021
+ const targetUrls = knowledgeRoot
1022
+ ? await buildTargetUrlMap(repoRoot, repo.baseUrl, repo.branch)
1023
+ : new Map();
1024
+ const refs = createRefResolver({ repoRoot, targetUrls, repoBaseUrl: repo.baseUrl, branch: repo.branch });
951
1025
  const decisions = [];
952
1026
  for (const fileName of mdFiles) {
953
1027
  const raw = await node_fs_1.promises.readFile(path.join(dirAbs, fileName), "utf8");
954
- const rec = await parseDecision(raw, fileName, repoRelDir, repo.baseUrl, repo.branch);
1028
+ const rec = await parseDecision(raw, fileName, repoRelDir, repo.baseUrl, repo.branch, refs);
955
1029
  decisions.push(rec);
956
1030
  }
957
- // Cortex構成なら、ホームと「records/ を持つディレクトリ」をすべて読み込む(旧構成では空)
958
- // ディレクトリ名はハードコードしない: 案件が新しいレコード種別のディレクトリを足せば、そのままタブとして表示される
959
- const knowledgeRoot = resolveKnowledgeRoot(dirAbs);
960
1031
  let home = null;
961
1032
  const sections = [];
962
1033
  if (knowledgeRoot) {
@@ -989,12 +1060,8 @@ async function build(opts) {
989
1060
  const categories = uniqueSorted(decisions.map((d) => d.category).filter(Boolean));
990
1061
  // 期間フィルタ用の月(YYYY-MM)を日付から導出し、降順(新しい順)にする
991
1062
  const months = uniqueSorted(decisions.map((d) => d.date.slice(0, 7)).filter((m) => /^\d{4}-\d{2}$/.test(m))).reverse();
992
- const repoRoot = knowledgeRoot ? path.dirname(knowledgeRoot) : path.dirname(dirAbs);
993
- const targetUrls = knowledgeRoot
994
- ? await buildTargetUrlMap(repoRoot, repo.baseUrl, repo.branch)
995
- : new Map();
996
1063
  const contentEntries = await scanContent(repoRoot, knowledgeRoot ? path.basename(knowledgeRoot) : null, repo.baseUrl, repo.branch);
997
- const graph = buildGraph(decisions, home, sections, contentEntries, targetUrls, repo.baseUrl, repo.branch);
1064
+ const graph = buildGraph(decisions, home, sections, contentEntries, targetUrls, repo.baseUrl, repo.branch, refs);
998
1065
  computeLayout(graph); // 各ノードに x/y を書き込む(クライアントはこれを初期位置に使いウォームアップ省略)
999
1066
  // データソースと自動化(fleet-status.json 由来)。無ければ undefined でセクション非表示。
1000
1067
  const fleet = await readFleetSources(repoRoot);
@@ -1055,7 +1122,7 @@ async function build(opts) {
1055
1122
  const sectionCount = sections.reduce((n, s) => n + s.records.length, 0);
1056
1123
  return { count: decisions.length + (home ? 1 : 0) + sectionCount, outDir: outAbs };
1057
1124
  }
1058
- async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1125
+ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch, refs) {
1059
1126
  const { data, content } = (0, gray_matter_1.default)(raw);
1060
1127
  const id = String(data.id ?? fileName.replace(/\.md$/i, ""));
1061
1128
  const title = String(data.title ?? stripIdPrefix(fileName));
@@ -1065,7 +1132,7 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1065
1132
  const summary = data.description != null ? String(data.description) : data.summary != null ? String(data.summary) : ""; // description優先(旧summaryは移行期互換)
1066
1133
  const deciders = toStringArray(data.deciders);
1067
1134
  const referencesRaw = toStringArray(data.references);
1068
- const references = referencesRaw.map((r) => resolveReference(r, repoBaseUrl, branch));
1135
+ const references = referencesRaw.map((r) => refs.resolve(r));
1069
1136
  const relations = toRelations(data.relations);
1070
1137
  // 本文先頭の H1 はタイトルと重複するため除去(詳細画面ではタイトルを別途表示する)
1071
1138
  const bodyHtml = externalLinksToNewTab((await marked_1.marked.parse(content, { async: true })).replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/, ""));
@@ -1095,25 +1162,50 @@ async function parseDecision(raw, fileName, repoRelDir, repoBaseUrl, branch) {
1095
1162
  fmErrors: validateFm(data),
1096
1163
  };
1097
1164
  }
1098
- function resolveReference(ref, repoBaseUrl, branch) {
1099
- const trimmed = ref.trim();
1100
- // markdownリンク [label](url)
1101
- const mdLink = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
1102
- if (mdLink) {
1103
- return { label: mdLink[1], url: mdLink[2] };
1104
- }
1105
- // 既にURL
1106
- if (/^https?:\/\//.test(trimmed)) {
1107
- return { label: trimmed, url: trimmed };
1108
- }
1109
- // リポジトリルート相対のローカルパスとみなす → GitHubのblobリンク
1110
- if (repoBaseUrl) {
1111
- return {
1112
- label: trimmed,
1113
- url: `${repoBaseUrl}/blob/${branch}/${encodePath(trimmed)}`,
1114
- };
1115
- }
1116
- return { label: trimmed, url: null };
1165
+ function createRefResolver(opts) {
1166
+ const { repoRoot, targetUrls, repoBaseUrl, branch } = opts;
1167
+ // **走査結果を流用しない。** scanContent は `.md` しか歩かず SCAN_SKIP_DIRS を除外するので、
1168
+ // 実在する `.txt`(Geminiメモ)や `tmp/` 配下のファイルを「実在しない」と誤判定して
1169
+ // **いま動いているリンクを消す**。参照は1リポジトリあたり数百件なので stat で十分。
1170
+ const seen = new Map();
1171
+ const exists = (relPath) => {
1172
+ const hit = seen.get(relPath);
1173
+ if (hit !== undefined)
1174
+ return hit;
1175
+ let ok = false;
1176
+ try {
1177
+ // パス要素に混ざる制御文字・絶対パス・親ディレクトリ参照は見に行かない
1178
+ if (relPath && !path.isAbsolute(relPath) && !relPath.split("/").includes("..")) {
1179
+ (0, node_fs_1.statSync)(path.join(repoRoot, relPath));
1180
+ ok = true;
1181
+ }
1182
+ }
1183
+ catch {
1184
+ ok = false;
1185
+ }
1186
+ seen.set(relPath, ok);
1187
+ return ok;
1188
+ };
1189
+ const resolve = (ref) => {
1190
+ const trimmed = String(ref ?? "").trim();
1191
+ if (!trimmed)
1192
+ return { label: trimmed, url: null };
1193
+ const mdLink = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
1194
+ if (mdLink)
1195
+ return { label: mdLink[1], url: mdLink[2] };
1196
+ if (/^https?:\/\//.test(trimmed))
1197
+ return { label: trimmed, url: trimmed };
1198
+ // 正本のURL(課題キー・material:・design:・minute:)。**ここが「ツールへ飛ぶ」の本体**
1199
+ const known = targetUrls.get(trimmed);
1200
+ if (known)
1201
+ return { label: trimmed, url: known };
1202
+ // 実在するファイルだけ blob URL にする
1203
+ if (repoBaseUrl && exists(trimmed)) {
1204
+ return { label: trimmed, url: `${repoBaseUrl}/blob/${branch}/${encodePath(trimmed)}` };
1205
+ }
1206
+ return { label: trimmed, url: null };
1207
+ };
1208
+ return { resolve, exists };
1117
1209
  }
1118
1210
  /** dirをリポジトリルート相対(posix)に。cwd配下でなければ basename を返す。 */
1119
1211
  function toRepoRelative(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.4.54",
3
+ "version": "0.4.55",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,