#!/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 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: '개수에 `따라가다/좇다`를 붙였습니다. `~에 비례해`, `~가 커지는 만큼`, `~와 같은 수로`, 또는 값을 그대로 적으세요.' }, { 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: 'role-symmetry', sev: ERR, 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: '사건을 명사구로 바꿨습니다. 동사로 적으세요.' }, { 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 = /(? /(자리|실례|출발점|대목)/.test(m[1])); 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: '무슨 일이 있었는지 적는 대신 그것이 무엇인지 이름 붙이고 닫았습니다. 거기서 실제로 일어나는 일을 동사로 적으세요.' }); } if (!rulesMode && namingEnd.length >= 4) { out.push({ id: 'naming-instead-of-telling', sev: ERR, msg: `문장을 「~것이 ~이다」로 닫은 곳이 ${namingEnd.length}군데입니다(참고 여섯 편은 글 하나에 0~2회). ` + `분류하지 말고 거기서 무엇이 일어나는지 적으세요.` }); } 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}부터|이번에는|공유합니다|공유하고자|다루겠습니다|보겠습니다|하겠습니다)/; // 안내 문장이 없다고 경고하지 않는다. 그 경고가 「필요하면 하나 두라」로 읽혀 평가·안내 문장을 // 보태는 쪽으로 작용했다. 문서는 대상을 설명하지 독자의 읽기를 지시하지 않는다. // 문장이 끝나지 않은 채 문단이 끝나는 줄 — 지우다 남은 조각이거나 마침표가 빠진 것. // 연결어미·조사로 끝나고 다음 줄이 비어 있을 때만 잡는다. 문단 안에서 줄을 바꾼 것은 // 다음 줄이 이어지므로 걸리지 않는다. 줄 끝에 인라인 코드가 있었으면(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+\.|:::|