pipeline: make tech-log-tree.json the one decomposition contract and enforce it

리뷰 두 건을 반영했다.

계약
- tech-log-tree.json 하나가 분해 계약이자 색인이다. 사람이 읽는 트리·Node Specification·
  후보 대장은 없어졌고, 문서에 남아 있던 그 개념을 걷어냈다
- candidateScope — 후보를 찾는 SSOT 범위. 접어 넣은 제2부·제3부는 근거이지 후보가 아니다
- sourceRepository — 분석한 저장소의 경로·리비전·판단 근거. 리비전을 모르면 null 로 두고
  지어내지 않는다. 갈래가 여럿이면 revisions
- 검사기: 계약 미채택·PENDING·PROMOTE↔글감 양방향·candidateScope·sourceRepository 를
  error/warn 으로 센다. 옛 스키마도 검사를 피하지 못한다. 테스트 22 → 31

기록 쓰기
- 템플릿 5종에 source·sourceRevision·topicName, Question 에 닫는 조건, 본문 없는 종류에서
  assets 제거. 고정 절 개수 삭제
- check_evidence.mjs — 인용한 코드가 SSOT 에 있는지, 앵커가 SSOT 를 가리키는지, 제목이
  계약과 같은지, 리비전이 저장소에 있는지. 게시된 기록에서 SSOT 와 다른 URL 을 잡았다

문체
- 문체 규칙의 정본을 ai-tells.md 로. explaining.md 의 질문체 제목·절 끝 대조 반복·그림 예고
  규칙을 삭제해 충돌을 없앴다. 첫 절 「설명 뒤에 평가를 붙이지 않는다」에 지우는 사례 네 유형
- voice 스킬의 「독자 쪽을 본다」를 자료에 오독 기록이 있을 때로 좁히고, 평가만 더한 예시를 교체
- check_prose: 안내 문장을 요구하던 경고 제거, 문장이 끝나지 않은 채 문단이 끝나는 조각 검사 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 12:39:20 +09:00
co-authored by Claude Fable 5.1
parent 73026cada6
commit 9d2a3725c5
54 changed files with 3583 additions and 871 deletions
@@ -13,6 +13,16 @@ import { readFileSync } from 'node:fs';
const ERR = 'error', WARN = 'warn';
// 관형형 어미 `-ㄴ`·`-ㄹ` 이 붙은 음절. 「적힌」·「만들」처럼 받침이 ㄴ 이나 ㄹ 인 글자를
// 코드포인트로 만든다. 「두 자리」·「세 자리」 같은 자릿수는 앞 글자에 받침이 없어 빠진다.
const ADNOMINAL = (() => {
const out = [];
for (let cho = 0; cho < 19; cho++)
for (let jung = 0; jung < 21; jung++)
for (const jong of [4, 8]) out.push(String.fromCharCode(0xAC00 + cho * 588 + jung * 28 + jong));
return out.join('');
})();
const RULES = [
{ id: 'idiom-follow', sev: ERR, re: /[를을]\s*(따라갔|따라\s*늘|따라\s*증가|좇았|좇아)/g,
msg: '개수에 `따라가다/좇다`를 붙였습니다. `~에 비례해`, `~가 커지는 만큼`, `~와 같은 수로`, 또는 값을 그대로 적으세요.' },
@@ -47,6 +57,21 @@ const RULES = [
re: /(나머지\s*(둘|셋|하나)[^.\n]{0,20}(대신|자리|메우)|어느\s*하나도[^.\n]{0,25}(대신|자리)|각각\s*다른\s*[가-힣]{1,8}(을|를|에서|에)\s*(맡|쓰이|담당)|그\s*자리를\s*(대신|메우)|서로\s*독립된\s*[가-힣\d]{1,6}\s*곳|만으로는[^.\n]{0,40}(속성|성질)을\s*대신)/g,
msg: '역할을 세어 대칭을 만들었습니다. 그것 하나만 있을 때 무엇이 실제로 통과하는지 적으세요.' },
// 무엇이 어디서 일어나는지를 「자리」로 대신한다. 「적힌 자리가 없다」·「그 자리에서 푼다」·
// 「검사기가 자리다」. 참고 여섯 편에 한 건도 없다(자리 0 · 옆 0 · 칸 0). 곳·부분으로 바꾸거나,
// 애초에 장소가 아니라 순서·동작이면 그것을 적는다.
// 관형형 어미와 지시어 뒤만 본다. 「앞 두 자리」 같은 자릿수는 걸리지 않는다.
{ id: 'spatial-metaphor', sev: ERR,
re: new RegExp(`(?:[${ADNOMINAL}]|는|던|[그이저])\\s*자리`, 'g'),
msg: '`자리`로 설명했습니다. 장소를 뜻하면 `곳`·`부분`으로 바꾸고, 장소가 아니면 거기서 무엇이 일어나는지 동사로 적으세요.' },
// 할 일이나 노출을 「그대로 남아 있다」로 닫는다. 누가 무엇을 해야 하는지 말하지 않고 상태만
// 보고한다. 참고 여섯 편에 한 건도 없다 — 그 글들의 「남아있다」 3건은 의존관계가 실제로 남는
// 것이라 형태가 다르다. 값·쿠키가 진짜 남는 문장은 걸리지 않는다.
{ id: 'leftover-state', sev: ERR,
re: /(그대로\s*남[아는])|((일|것|부분|점|과제|몫)(은|이|도)\s*(아직\s*)?남[아는])|(아직\s*남아\s*있)/g,
msg: '무엇이 「남아 있다」로 닫았습니다. 누가 무엇을 해야 하는지, 또는 무엇을 아직 막지 못하는지로 적으세요.' },
{ id: 'nominalized', sev: ERR, re: /(채워진\s*목록\s*수|준비한\s*SQL\s*문장|획득한[^.\n]{0,10}객체\s*수|[가-힣]+에\s*대한\s*(측정|비교|확인|분석))/g,
msg: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' },
@@ -118,7 +143,7 @@ function positiveChecks(text, lines, docMode, rulesMode) {
// 문서 어디에든 `약어(...)` 형태가 있으면 푼 것으로 본다
if (!new RegExp(a + '\\s*\\(').test(text)) {
out.push({ id: 'unexpanded-acronym', sev: WARN,
msg: `약어 \`${a}\`을(를) 글 어디에서도 풀지 않았습니다. 처음 나오는 자리\`${a}(전체 이름, 우리말 뜻)\`으로 폅니다.` });
msg: `약어 \`${a}\`을(를) 글 어디에서도 풀지 않았습니다. 처음 나오는 \`${a}(전체 이름, 우리말 뜻)\`으로 폅니다.` });
}
}
@@ -156,12 +181,12 @@ function positiveChecks(text, lines, docMode, rulesMode) {
for (const m of alwaysBad) {
out.push({ id: 'naming-instead-of-telling', sev: ERR, index: m.index,
excerpt: text.slice(Math.max(0, m.index - 30), m.index + m[0].length),
msg: '무슨 일이 있었는지 적는 대신 그것이 무엇인지 이름 붙이고 닫았습니다. 그 자리에서 실제로 일어나는 일을 동사로 적으세요.' });
msg: '무슨 일이 있었는지 적는 대신 그것이 무엇인지 이름 붙이고 닫았습니다. 거기서 실제로 일어나는 일을 동사로 적으세요.' });
}
if (!rulesMode && namingEnd.length >= 4) {
out.push({ id: 'naming-instead-of-telling', sev: ERR,
msg: `문장을 「~것이 ~이다」로 닫은 자리가 ${namingEnd.length}입니다(참고 여섯 편은 글 하나에 0~2회). `
+ `분류하지 말고 그 자리에서 무엇이 일어나는지 적으세요.` });
msg: `문장을 「~것이 ~이다」로 닫은 곳이 ${namingEnd.length}군데입니다(참고 여섯 편은 글 하나에 0~2회). `
+ `분류하지 말고 거기서 무엇이 일어나는지 적으세요.` });
}
if (!rulesMode && sentences.length >= 8 && kinds.size <= 1) {
@@ -173,11 +198,27 @@ function positiveChecks(text, lines, docMode, rulesMode) {
// 4. 독자를 데리고 다니는 문장
const steer = /(살펴보|알아보|파보|확인해\s*봅|정리해\s*보|소개해\s*보|다뤄\s*보|짚어\s*보|나중에\s*살펴|딴 길로|먼저[^\n]{0,25}부터|이번에는|공유합니다|공유하고자|다루겠습니다|보겠습니다|하겠습니다)/;
// 강제하지 않는다. 강제했더니 `먼저 ~를 구분해야 합니다` 같은 지도형 문장이 절마다 붙어서
// 문장이 아니라 구조가 기계처럼 읽히게 됐다.
if (!rulesMode && !steer.test(text)) {
out.push({ id: 'no-reader-steering', sev: WARN,
msg: '독자를 안내하는 문장이 없습니다. 필요하면 하나 두되, 없어도 됩니다.' });
// 안내 문장이 없다고 경고하지 않는다. 그 경고가 「필요하면 하나 두라」로 읽혀 평가·안내 문장을
// 보태는 쪽으로 작용했다. 문서는 대상을 설명하지 독자의 읽기를 지시하지 않는다.
// 문장이 끝나지 않은 채 문단이 끝나는 줄 — 지우다 남은 조각이거나 마침표가 빠진 것.
// 연결어미·조사로 끝나고 다음 줄이 비어 있을 때만 잡는다. 문단 안에서 줄을 바꾼 것은
// 다음 줄이 이어지므로 걸리지 않는다. 줄 끝에 인라인 코드가 있었으면(strip 이 공백으로
// 바꿔 둔 자리) 판단할 수 없으니 건너뛴다. 「이름 : 값」 줄도 문장이 아니라 건너뛴다.
const DANGLING = /(때|고|며|면|를|을|는|은|이|가|에서|으로|에|와|과|도|서|아|어|지|니|라서|라|의)$/;
let inFront = lines[0] === '---';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (inFront) { if (i > 0 && line === '---') inFront = false; continue; }
const t = line.trimEnd();
if (!t.trim() || t !== line) continue; // 빈 줄 · 끝에 공백(인라인 코드 자리)
if (/^\s*(#|-|\*|\d+\.|:::|<!--|\||>)/.test(t) || t.includes(' : ')) continue;
const next = (lines[i + 1] ?? '').trim();
if (next !== '') continue; // 문단이 이어진다
if (DANGLING.test(t)) {
out.push({ id: 'dangling-sentence', sev: ERR, line: i + 1,
msg: `문장이 끝나지 않은 채 문단이 끝납니다("…${t.slice(-24)}"). 지우다 남은 조각이거나 마침표가 빠졌습니다.` });
}
}
// 독자에게 사고를 지시하는 문장 — 사건으로 바로 들어가면 될 자리