keycloak-session-store 와 TechLog 는 SSOT 만 있고 분해 계약이 없었다. 두 프로젝트 모두 후보를 처분과 함께 남기고 PROMOTE 만 주제로 올린다. keycloak-session-store 주제 6 · 글감 33 · 후보 42 (제외 9) TechLog 주제 13 · 글감 56 · 후보 91 (제외 35) TechLog 는 저장소가 셋이라 sourceRepository 를 목록으로 적는다. 설계 패키지는 이 기계에 체크아웃이 없고 반입한 쪽의 MANIFEST.sha256 이 세 계약의 원본 리비전을 고정하므로 그것을 리비전으로 적었다. 검사기 둘이 그 모양을 읽게 고친다 — verify-tech-log-tree.py 는 목록의 항목마다 path·revision·verified 를 보고, check_evidence.mjs 는 체크아웃이면 git 으로, 매니페스트면 그 파일이 리비전을 적고 있는지로 대조한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
151 lines
7.6 KiB
JavaScript
Executable File
151 lines
7.6 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");
|
|
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("<!-- 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)) 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 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);
|