Files
document-haness/.agents/skills/writing-tech-log-records/scripts/check_evidence.mjs
T
DongHyeonkaandClaude Opus 5 ab59130196 chore: 이전 세션이 남긴 변경을 커밋한다
이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 11:02:02 +09:00

178 lines
9.1 KiB
JavaScript
Executable File

#!/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");
// 대상이 성립하지 않는다 — CLAUDE.md 「검사」 절의 표. exit 2 로 error 로 센다
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;
// Setup 의 자리표시자. 읽는 사람의 실험대에서 값이 달라지는 자리라 SSOT 의 실측값과 글자가
// 다르다. 그 자리만 와일드카드로 두고 나머지는 한 글자씩 대조한다. 문법은
// references/writing-each-kind.md 「Setup — 자리표시자」 가 정한다.
// 셸 변수와 갈라야 해서 `${...}` 를 안 쓴다 — 같은 가이드가 `$SID`·`$TOK` 를 진짜 변수로 쓴다.
const PLACEHOLDER = /\{\{[A-Z][A-Z0-9_]*\}\}/;
const PLACEHOLDER_G = /\{\{[A-Z][A-Z0-9_]*\}\}/g;
// 필자가 그린 흐름도의 글리프. `주입 ① ─▶ 검증 §1` 꼴은 코드가 아니라 그림이다
const FLOW = /[─━│┃┌┐└┘├┤┬┴┼╭╮╯╰▶◀►◄→←↔⇒⇐↑↓]/;
const RE_META = /[.*+?^${}()|[\]\\]/g;
// 자리표시자가 없으면 지금까지처럼 통째로 찾는다. 있으면 그 자리만 「값 하나」로 열어 두는데,
// 따옴표와 공백은 못 넘게 해서 와일드카드가 엉뚱한 구간을 삼키지 않도록 한다.
function inSsot(line) {
const n = norm(line);
if (!PLACEHOLDER.test(n)) return ssot.includes(n);
const pattern = n.split(PLACEHOLDER_G)
.map(part => part.replace(RE_META, "\\$&"))
.join("[^'\"\\s]+");
return new RegExp(pattern).test(ssot);
}
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("<!-- body:start -->");
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) && FLOW.test(t)) continue;
if (!inSsot(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 declared = tree.sourceRepository || {};
const repos = Array.isArray(declared) ? declared : [declared];
for (const repo of repos) {
const name = repo.name || project;
if (!repo.path) { findings.push(["tech-log-tree.json", "sourceRepository.path 가 없다", name]); continue; }
if (!existsSync(repo.path)) { findings.push(["tech-log-tree.json", "저장소 경로가 없다", `${name}${repo.path}`]); continue; }
// 갈래가 여럿이면 revisions 로 적는다. 둘 다 없으면 verify-tech-log-tree.py 가 warn 을 낸다
const revs = repo.revision ? { revision: repo.revision } : (repo.revisions || {});
// 체크아웃이 없는 저장소는 반입한 쪽의 매니페스트가 리비전을 고정한다. 그럴 때는
// path 가 그 파일이고, git 대신 그 파일이 리비전을 적고 있는지 본다
const isCheckout = statSync(repo.path).isDirectory();
const manifest = isCheckout ? "" : readFileSync(repo.path, "utf8");
for (const [label, rev] of Object.entries(revs)) {
if (isCheckout) {
try {
execSync(`git -C ${JSON.stringify(repo.path)} cat-file -e ${rev}^{commit}`, { stdio: "ignore" });
} catch {
findings.push(["tech-log-tree.json", "그 리비전이 저장소에 없다", `${name} · ${label} = ${rev}`]);
}
} else if (!manifest.includes(rev)) {
findings.push(["tech-log-tree.json", "매니페스트가 그 리비전을 적고 있지 않다", `${name} · ${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);