#!/usr/bin/env node // 한국 기술 블로그 문장 규범 검사기. // 표면 패턴만 본다. 뜻은 못 본다. 통과가 곧 좋은 글이라는 뜻은 아니다. // // node scripts/check_prose.mjs [--doc|--rules] [--warn] // --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: '방금 보여 준 것을 다시 선언합니다. 지우세요.' }, // 마무리가 되풀이로 끝나는 것을 본다. 17386 은 「지금까지 ~ 소개했습니다」로 열고 회고로 // 닫으므로 그 자체는 defect 가 아니다. 뒤에 남은 일이 오는지는 사람이 본다 — 그래서 경고다. { id: 'closing-recap', sev: WARN, re: /(지금까지|여기까지)[^.\n]{0,80}(살펴봤|살펴보았|알아봤|알아보았|소개했|정리했|다뤘|다루었)/g, msg: '앞 내용을 다시 늘어놓았습니다. 이 뒤에 남은 일이나 감수한 것이 오면 두고, 이것으로 끝나면 지우세요.' }, // 검사기의 종결어미 수를 채우려고 끼워 넣는 물음. 「왜 ~할까?」·「어떤 ~할까?」처럼 그 절이 // 실제로 답하는 물음은 참고 글도 쓴다(22396). 잡는 것은 답이 예·아니오뿐인 수사 의문이다. { id: 'rhetorical-question', sev: ERR, re: /[^\n?]{4,60}([가-힣]\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: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' }, { 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: /(? m.replace(/[^\n]/g, ' ')) .replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length)) // 표와 인용은 「쓰지 않는다」 예시가 사는 자리다. 규칙을 적은 문서가 그 규칙을 어긴 것으로 // 잡히지 않게 줄을 통째로 비운다. 줄 번호는 유지한다. .split('\n') .map((line) => (/^\s*(\||>)/.test(line) ? ' '.repeat(line.length) : line)) .join('\n'); } 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 = /(?= 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);