R4 · R13 · R6 · R3. 네 가지가 같은 자리를 본다 — 검사기가 대상을 못 찾았을 때 무엇을
내는가.
세 상태를 가른다 (CLAUDE.md 「검사」 절의 표).
봤고 괜찮다 exit 0 문제 없음 · error 0
대상이 성립하지 않는다 exit 2 대상이 성립하지 않는다 — <이유>
볼 것이 아직 없다 exit 0 기록 0건 — 아직 쓴 기록이 없다
R4 — 없는 프로젝트를 주면 여섯 중 넷이 초록을 냈다. `verify-tech-log-tree.py` 는 그것을
「프로젝트 1 · error 0 · PASS」로 셌다 — 오타 한 번이면 검사를 다 돈 것처럼 보인다.
판정은 `techlog.check_targets()` 하나로 모은다. 여섯 곳에 같은 규칙을 따로 쓰면 다음에
하나만 어긋난다. `check_evidence.mjs` 는 이미 exit 2 라 문구만 맞춘다.
R13 — 프로젝트 이름 쪽만 고치면 `--file` 로 오타를 내는 순간 다시 조용히 0건이 된다.
`techlog.check_files()` 로 같은 자리에 둔다. `preview-figure.py` 는 인자가 아예 없을 때
exit 1 을 냈는데 그것도 「대상이 성립하지 않는다」다.
R6 — 두 자리를 함께 고쳐야 했다.
(a) `verify_projects()` 가 `docs/*/tech-log-studio` 만 훑어 계약 없는 프로젝트가
목록에서 사라졌다. 기준을 `final/document.md` 로 바꾼다 — SSOT 가 있으면 대상이다.
(b) `verify-tech-log-tree.py:194` 가 「분해 계약 없음」을 warn 으로 냈다. CLAUDE.md 는
「계약 미채택도 error 다 — 경고로 두면 옛 스키마로 남아 있는 한 검사를 피한다」고
적어 두었는데, 경고로 두었더니 실제로 그렇게 됐다.
R3 — `verify-pipeline.py` 가 `check-figure-text.py` 와 `check_evidence.mjs --repo` 를
프로젝트마다 돌린다(`OUTPUT CHECKS`). 게시 전에 돌리라고 적어 둔 검사인데 전체 훑기가
부르지 않아 결함이 있는 채로 PASS 로 보고됐다. `check-required-content.py` 자리는 주석으로
남겨 둔다 — 그 파일이 들어온 뒤에 더한다.
회귀 `scripts/tests/test_no_target.py` 7건 (92 → 99). 「실재하는 경로는 통과한다」 대조를
함께 넣는다 — 무조건 거절로 성공률을 올리는 것이 R-003 이 든 실패다. 판정을 일부러
되돌려 FAILED 가 나는 것을 확인한 뒤 복구했다.
알려진 부채는 그대로 둔다. `verify-pipeline.py` 는 exit 1 이다 — 미준수 런 1건과
`OUTPUT CHECKS` 5건, 그리고 `ca-tmpl` 의 계약 없음이 이제 `TECH LOG TREES` 에서도 보인다.
같은 사실이고 두 번 세지 않는다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wp9jNbePAmWc5jQwCYhK9v
155 lines
7.7 KiB
JavaScript
Executable File
155 lines
7.7 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;
|
|
|
|
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);
|