#!/usr/bin/env node // 기록이 인용한 것이 정말 SSOT 에 있는지 본다. // // node check_evidence.mjs <프로젝트> // node check_evidence.mjs <프로젝트> --repo # 저장소까지 대조 (sourceRepository.path 필요) // // 세 가지를 본다. // 1. 본문 코드블록의 각 줄이 SSOT 안에 있는가 // 2. frontmatter 의 source 앵커가 SSOT 를 가리키는가 // 3. 기록의 title 이 계약(tech-log-tree.json)의 title 과 같은가 // // 검사기가 못 보던 자리다. `verify-tech-log-tree.py` 는 slug 와 칸의 존재만 보고, // 인용한 코드가 실재하는지도 제목이 계약과 같은지도 보지 않는다. import { readFileSync, readdirSync, statSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; import { execSync } from "node:child_process"; const [project, ...flags] = process.argv.slice(2); if (!project) { console.error("usage: check_evidence.mjs <프로젝트> [--repo]"); process.exit(2); } const withRepo = flags.includes("--repo"); const root = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); const base = join(root, "docs", project); const treePath = join(base, "tech-log-studio", "tech-log-tree.json"); if (!existsSync(treePath)) { console.error(`${project}: tech-log-tree.json 이 없다`); process.exit(2); } const tree = JSON.parse(readFileSync(treePath, "utf8")); const ssotRel = tree.ssot || "final/document.md"; const norm = s => s.replace(/\s+/g, " ").trim(); const ssot = norm(readFileSync(join(base, ssotRel), "utf8")); // 계약이 말하는 제목 const contractTitle = new Map(); for (const topic of Object.values(tree.topics || {})) for (const [kind, items] of Object.entries(topic.kinds || {})) for (const n of items) if (n.slug) contractTitle.set(`${kind}:${n.slug}`, n.title || ""); // ``` 로 열고 닫는 펜스를 짝짓는다. ```java label="…" 도 여는 표시다 function codeBlocks(text) { const out = []; let inside = false, lang = "", buf = []; for (const line of text.split("\n")) { const t = line.trimStart(); if (t.startsWith("```")) { if (inside) { out.push([lang, buf.join("\n")]); buf = []; inside = false; lang = ""; } else { inside = true; lang = (t.slice(3).trim().split(/\s+/)[0] || "").toLowerCase(); } continue; } if (inside) buf.push(line); } return out; } // ```text 는 필자가 짠 요약표·흐름도에 쓰인다. 정렬 공백이 열 구분자라 줄 단위로 대조하면 // 전부 오탐이 된다. 그래서 text 펜스는 줄이 아니라 그 안의 식별자·URL·수치만 본다. const PROSE_FENCE = new Set(["text", "", "txt", "console", "diff"]); // 맨몸 영단어(observation, self-report …)는 필자가 붙인 열 이름이라 제외하고, // 경로·URL·점 있는 식별자처럼 저장소에서 온 것만 본다. const TOKEN = /(?:https?:\/\/[^\s"'`,)]+|\/[A-Za-z0-9_][A-Za-z0-9_./-]{4,}|[A-Za-z_][A-Za-z0-9_]*(?:[.][A-Za-z0-9_]+)+)/g; const findings = []; const studio = join(base, "tech-log-studio"); for (const topicDir of readdirSync(studio)) { const tp = join(studio, topicDir); if (!statSync(tp).isDirectory() || topicDir.startsWith("_")) continue; for (const kind of readdirSync(tp)) { const kp = join(tp, kind); if (!statSync(kp).isDirectory()) continue; for (const file of readdirSync(kp).filter(f => f.endsWith(".md"))) { const p = join(kp, file); const text = readFileSync(p, "utf8"); const fm = text.startsWith("---") ? text.slice(4, text.indexOf("\n---", 3)) : ""; const get = k => (fm.match(new RegExp(`^${k}: (.*)$`, "m")) || [, ""])[1].trim(); const slug = get("slug"), title = get("title"); // 1. 인용한 코드가 SSOT 에 있는가 const bodyStart = text.indexOf(""); const body = bodyStart === -1 ? text : text.slice(bodyStart); for (const [lang, block] of codeBlocks(body)) { if (PROSE_FENCE.has(lang)) { for (const tok of block.match(TOKEN) || []) if (tok.length >= 8 && !ssot.includes(tok)) findings.push([file, "인용한 식별자가 SSOT 에 없다", tok.slice(0, 90)]); continue; } for (const raw of block.split("\n")) { const t = raw.trim(); if (t.length < 20) continue; if (/^(\/\/|\*|\/\*\*|#|--|>|\|)/.test(t)) continue; if (/[가-힣]/.test(t)) continue; // 한글이 섞인 줄은 코드가 아니다 if (!ssot.includes(norm(t))) findings.push([file, "인용한 코드가 SSOT 에 없다", t.slice(0, 90)]); } } // 2. source 앵커가 SSOT 를 가리키는가 const src = (fm.match(/^source:\n((?:\s+-\s.*\n)+)/m) || [, ""])[1]; const anchors = src.split("\n").map(l => l.replace(/^\s*-\s*/, "").trim()).filter(Boolean); if (anchors.length && !anchors.some(a => a.includes(ssotRel))) findings.push([file, "source 가 SSOT 를 가리키지 않는다", anchors.join(" · ").slice(0, 90)]); // 3. 제목이 계약과 같은가 const key = `${kind}:${slug}`; if (contractTitle.has(key) && contractTitle.get(key) !== title) findings.push([file, "제목이 계약과 다르다", `계약 "${contractTitle.get(key)}" ≠ 기록 "${title}"`]); } } } // 4. (--repo) 저장소가 실재하고 리비전이 맞는가 if (withRepo) { const repo = tree.sourceRepository || {}; if (!repo.path) findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", ""]); else if (!existsSync(repo.path)) findings.push(["tech-log-tree.json", "저장소 경로가 없다", repo.path]); else { // 갈래가 여럿이면 revisions 로 적는다. 둘 다 없으면 verify-tech-log-tree.py 가 warn 을 낸다 const revs = repo.revision ? { revision: repo.revision } : (repo.revisions || {}); for (const [label, rev] of Object.entries(revs)) { try { execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" }); } catch { findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${label} = ${rev}`]); } } } } const grouped = new Map(); for (const [f, rule, detail] of findings) { if (!grouped.has(rule)) grouped.set(rule, []); grouped.get(rule).push(`${f} — ${detail}`); } console.log(`\n[${project}] 증빙 대조${withRepo ? " (저장소 포함)" : ""}`); if (!findings.length) { console.log(" 문제 없음"); process.exit(0); } for (const [rule, items] of [...grouped].sort((a, b) => b[1].length - a[1].length)) { console.log(` ✗ ${String(items.length).padStart(4)} ${rule}`); for (const it of items.slice(0, 3)) console.log(` · ${it}`); if (items.length > 3) console.log(` … 외 ${items.length - 3}건`); } console.log(`\n합계 ${findings.length}건`); process.exit(1);