feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,213 @@
#!/usr/bin/env node
// 한국 기술 블로그 문장 규범 검사기.
// 표면 패턴만 본다. 뜻은 못 본다. 통과가 곧 좋은 글이라는 뜻은 아니다.
//
// node scripts/check_prose.mjs [--doc|--rules] [--warn] <file.md ...>
// --doc 글 전체 기준(도입·차례·마무리)까지 검사
// --rules 규칙 문서(README·CLAUDE.md·스킬 문서)용. 읽는 사람을 데리고 다니는 규칙을 끈다
// --warn 판단이 필요한 경고도 함께 출력
//
// 기준선: 우아한형제들 기술블로그 5편이 error 0건으로 통과한다.
// 규칙을 더할 때는 그 5편을 다시 돌려서 통과하는지 확인한다.
import { readFileSync } from 'node:fs';
const ERR = 'error', WARN = 'warn';
const RULES = [
{ id: 'idiom-follow', sev: ERR, re: /[를을]\s*(따라갔|따라\s*늘|따라\s*증가|좇았|좇아)/g,
msg: '개수에 `따라가다/좇다`를 붙였습니다. `~에 비례해`, `~가 커지는 만큼`, `~와 같은 수로`, 또는 값을 그대로 적으세요.' },
{ id: 'role-noun', sev: ERR, re: /(비교\s*대상이\s*아니|최소한의\s*선|기준선|구조적\s*문제|증가\s*형태|의\s*실체)/g,
msg: '논증에서 맡은 역할로 불렀습니다. 그 대상의 이름과 실제로 일어난 일을 적으세요.' },
// 글/코드 자체를 가리키는 메타 상황 서술만 잡는다. 세상의 상태를 말하는 `~는 상황입니다`는 정상.
{ id: 'scene-setter', sev: ERR,
re: /((이|본|해당)\s*(코드|절|장|문서|글|부분|예제)[^.\n]{0,40}(상황이다|상황입니다)|(이|본|해당)\s*(절|장|문서|글)은[^.\n]{0,30}에\s*대한\s*내용(이다|입니다))/g,
msg: '설명을 미루는 상황 서술입니다. 조건이 필요하면 설명 문장 안에 `~지만`, `~인데`로 넣으세요.' },
{ id: 'wrap-up', sev: ERR, re: /(이\s*(관찰|결과|측정)은[^.\n]{0,40}(보여준|드러낸|말해\s*준)|이는[^.\n]{0,30}보여준다)/g,
msg: '방금 보여 준 것을 다시 선언합니다. 지우세요.' },
{ id: 'nominalized', sev: ERR, re: /(채워진\s*목록\s*수|준비한\s*SQL\s*문장|획득한[^.\n]{0,10}객체\s*수|[가-힣]+에\s*대한\s*(측정|비교|확인|분석))/g,
msg: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' },
{ id: 'ui-chain', sev: ERR, re: /[가-힣A-Za-z0-9)\]]+의\s*[가-힣A-Za-z0-9]+의\s*[가-힣A-Za-z0-9]+의/g,
msg: '`의`가 세 겹입니다. 동사로 푸세요.' },
// 아래는 판단이 필요한 자리. 참고 글도 문맥에 따라 쓴다.
{ id: 'slogan', sev: WARN, re: /(결국\s*문제는|단순히[^.\n]{0,30}가\s*아니라|비용이[^.\n]{0,20}(이동|옮겨)|새로운\s*책임이\s*생|정반대의?\s*(결과|곡선)|회계\s*항등식)/g,
msg: '원문에 없는 결론·표어일 수 있습니다. 원문이 같은 주장을 했는지 확인하세요.' },
{ id: 'bare-relation', sev: WARN, re: /(?<!연관\s)(?<!상속\s)관계(가\s|는\s|를\s|의\s|\s*때문)/g,
msg: '`관계`가 어느 매핑인지 말하지 않을 수 있습니다. 필드·애너테이션·외래 키 이름을 적으세요.' },
{ id: 'reading-order', sev: WARN, re: /(읽으면\s*안\s*된다|주의해서\s*보|눈여겨\s*보)/g,
msg: '독자에게 읽는 법을 지시합니다. 그렇게 읽게 만드는 관측을 적으세요.' },
{ id: 'forced-contrast', sev: WARN, re: /(^|[.\n]\s*)(반면|반대로|이에\s*비해)/g,
msg: '대비어가 문장 앞에 섰습니다. 대비가 정말 필요한지 확인하세요.' },
];
// 풀지 않아도 되는 말. 업계에서 그대로 쓰거나, SQL·자리표시자.
const ACRONYM_OK = new Set([
'SQL','API','ID','URL','URI','JSON','YAML','XML','HTML','CSS','HTTP','HTTPS','CPU','GPU','RAM',
'JVM','DB','UI','UX','IO','OK','TTL','CI','CD','AI','ML','LLM','ORM','JDBC','JPA','MVC','REST',
'UUID','TCP','UDP','DNS','CSV','PDF','PNG','SVG','RPS','TPS','QPS','QA','PK','FK','GPS','CTR',
'SDK','IDE','CLI','GUI','AWS','GCP','SQS','SNS','JSX','TSX','DTO','VO','CRUD','ES','NPE','GC',
'IT','SRE','PR','MR','OS','VM','K8S','MSA','TDD','DDD','JWT','SSO','OTP','ACL','CORS','CDN',
'SELECT','FROM','WHERE','INSERT','UPDATE','DELETE','JOIN','GROUP','ORDER','TABLE','INDEX','POINT',
'CANCEL','GREEN','RED','TODO','NOTE','CODE','BLOCK','AND','OR','NOT','NULL','TRUE','FALSE',
]);
function strip(src) {
return src
.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' '))
.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
}
function positiveChecks(text, lines, docMode, rulesMode) {
const out = [];
const sentences = text.split(/(?<=[.?!])\s+|\n{2,}/).map(x => x.trim()).filter(Boolean);
// 1. 정의가 첫 사용보다 뒤에 오는가
const defRe = /`([^`\n]{2,60})`\s*(?:는|은)\s+[^\n]{5,}?(?:입니다|이다|말한다|뜻한다|의미합니다|의미한다)/g;
let m;
const flagged = new Set();
while ((m = defRe.exec(text)) !== null) {
const name = m[1];
if (flagged.has(name)) continue;
const firstAt = text.indexOf('`' + name + '`');
if (firstAt >= 0 && firstAt < m.index) {
flagged.add(name);
out.push({ id: 'define-after-use', sev: ERR,
msg: `\`${name}\`을(를) 먼저 쓰고 뒤에서 정의합니다. 정의는 첫 사용 바로 앞에 둡니다.` });
}
}
// 2. 글 전체 어디에서도 풀지 않은 약어
const acroRe = /(?<![A-Za-z0-9_.\/-])([A-Z]{2,6})(?![A-Za-z0-9_])/g;
const seen = new Set();
while ((m = acroRe.exec(text)) !== null) {
const a = m[1];
if (ACRONYM_OK.has(a) || seen.has(a)) continue;
seen.add(a);
// 문서 어디에든 `약어(...)` 형태가 있으면 푼 것으로 본다
if (!new RegExp(a + '\\s*\\(').test(text)) {
out.push({ id: 'unexpanded-acronym', sev: WARN,
msg: `약어 \`${a}\`을(를) 글 어디에서도 풀지 않았습니다. 처음 나오는 자리에 \`${a}(전체 이름, 우리말 뜻)\`으로 폅니다.` });
}
}
// 3. 종결어미가 한 가지뿐인가
const kinds = new Set();
for (const st of sentences) {
if (/(습니다|았습니다|었습니다)[.!]?$/.test(st)) kinds.add('습니다');
if (/입니다[.!]?$/.test(st)) kinds.add('입니다');
if (/(했다|이다|였다|된다|한다)[.!]?$/.test(st)) kinds.add('한다');
if (/(겠습니다|하겠습니다|보겠습니다)[.!]?$/.test(st)) kinds.add('겠습니다');
if (/까요\??$/.test(st) || /\?$/.test(st)) kinds.add('물음');
if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(st)) kinds.add('청유');
}
if (!rulesMode && sentences.length >= 8 && kinds.size <= 1) {
out.push({ id: 'monotone-endings', sev: ERR,
msg: `문장 ${sentences.length}개가 모두 같은 종결어미입니다. 예고(~살펴보겠습니다)·물음(~할까요?)·권유(~봅시다)를 섞습니다.` });
}
// 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: '독자를 안내하는 문장이 없습니다. 필요하면 하나 두되, 없어도 됩니다.' });
}
// 독자에게 사고를 지시하는 문장 — 사건으로 바로 들어가면 될 자리
const instruct = text.match(/(구분해야 합니다|주의해야 합니다|유의해야 합니다|기억해야 합니다|이해해야 합니다|먼저 결론|짚고 넘어)/g);
if (!rulesMode && instruct) {
out.push({ id: 'instructing-the-reader', sev: ERR,
msg: `독자에게 사고를 지시하는 문장이 ${instruct.length}개 있습니다(예: "${instruct[0]}"). 사건을 바로 적으세요.` });
}
// 본문이 이미 말한 것을 끝에서 목록으로 다시 포장 — 에이전트 Findings 출력처럼 읽힌다
const fh = lines.findIndex(l => /^#{2,4}\s.*(공백|한계|주의|잘못 |남은 문제|정리하면|Findings|알아야 )/.test(l));
if (!rulesMode && fh >= 0) {
const bullets = lines.slice(fh + 1, fh + 25).filter(l => /^\s*[-*+\d]/.test(l)).length;
if (bullets >= 5) {
out.push({ id: 'findings-list', sev: ERR,
msg: `"${lines[fh].replace(/^#+\s*/,'')}" 아래 항목이 ${bullets}개입니다. 본문이 이미 설명한 것을 끝에서 목록으로 다시 포장하지 않습니다. 한계는 그것이 제한하는 대상 옆에 둡니다.` });
}
}
if (!docMode) return out;
const heads = lines.filter(l => /^#{2,4}\s/.test(l)).map(l => l.replace(/^#+\s*/, '').trim());
// 5. 선수 지식을 주는 곳
const audienceLine = /(대상으로|읽는 분|독자|알고 있는 분|아시는 분|분들이라면|경험이 없어도|읽으시면|도움이 되)/.test(text);
// 용어 절 제목(`X란?`, `X가 뭐죠?`, `X를 아시나요?`)이 있으면 선수 지식을 그쪽에서 준 것으로 본다
const defSection = heads.some(h => /(\?|이란| | |아시나요|무엇인가| 뭔가)/.test(h));
if (!audienceLine && !defSection) {
out.push({ id: 'no-prereq', sev: WARN,
msg: '선수 지식을 주는 곳이 없습니다. 도입에 독자·선수 지식 한 줄을 넣거나, `X란?` 형태의 용어 절을 둡니다.' });
}
// 6. 차례 예고 — 같은 제목 묶음을 반복하는 글은 제목이 차례 노릇을 하므로 면제
const dup = heads.length - new Set(heads).size;
const staged = heads.filter(h => /^(\d+[).\s]|\d+\s*단계|[-])/.test(h)).length >= 2;
if (heads.length >= 3 && dup < 2 && !staged &&
!/(다음 순서대로|본 글에서는|이 글에서는|순서로 소개|차례로|먼저[^\n]{0,60}부터|살펴보고|공유합니다|공유하고자)/.test(text)) {
out.push({ id: 'no-route', sev: WARN,
msg: `절이 ${heads.length}개인데 차례를 알리는 문장이 없습니다.` });
}
// 7. 마무리
if (!/(지금까지|마무리|맺으며|맺는 글|살펴봤습니다|살펴보았습니다|정리하면|회고)/.test(text)) {
out.push({ id: 'no-closing', sev: WARN,
msg: '마무리가 없습니다. "지금까지 ~를 살펴봤습니다 → 줄거리 한 문장 → 그 결과 ~"로 닫습니다.' });
}
return out;
}
const args = process.argv.slice(2);
const docMode = args.includes('--doc');
// 규칙 문서는 「~한다」로 끝나는 항목의 나열이 맞다. 거기에 예고·물음·권유를 섞으면
// 오히려 이상해지므로 그 두 규칙만 끈다. 나머지 규칙은 그대로 돈다.
const rulesMode = args.includes('--rules');
const showWarn = args.includes('--warn');
const files = args.filter(a => !a.startsWith('--'));
let errTotal = 0;
for (const file of files) {
const raw = readFileSync(file, 'utf8');
const text = strip(raw);
const lines = text.split('\n');
const hits = [];
lines.forEach((line, i) => {
for (const rule of RULES) {
rule.re.lastIndex = 0;
let m;
while ((m = rule.re.exec(line)) !== null) {
hits.push({ line: i + 1, id: rule.id, sev: rule.sev, msg: rule.msg, match: m[0].trim() });
if (m.index === rule.re.lastIndex) rule.re.lastIndex++;
}
}
});
for (const p of positiveChecks(text, lines, docMode, rulesMode)) hits.push({ line: null, ...p, match: null });
const errs = hits.filter(h => h.sev === ERR);
const warns = hits.filter(h => h.sev === WARN);
errTotal += errs.length;
const name = file.replace(/^.*\//, '');
if (errs.length === 0) console.log(`OK ${name}${warns.length ? ` (경고 ${warns.length}건)` : ''}`);
else console.log(`FAIL ${name} — error ${errs.length}${warns.length ? ` · 경고 ${warns.length}` : ''}`);
for (const h of errs) {
console.log(` ${h.line ? name + ':' + h.line + ' ' : ''}[${h.id}]${h.match ? ` "${h.match}"` : ''}\n ${h.msg}`);
}
if (showWarn) for (const h of warns) {
console.log(` · ${h.line ? name + ':' + h.line + ' ' : ''}[${h.id}]${h.match ? ` "${h.match}"` : ''}\n ${h.msg}`);
}
}
process.exit(errTotal === 0 ? 0 : 1);