Files
document-haness/.agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs
T
DongHyeonkaandClaude Fable 5.1 9d2a3725c5 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>
2026-09-07 12:39:20 +09:00

313 lines
20 KiB
JavaScript

#!/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 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: /(?<!연관\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))
// 표와 인용은 「쓰지 않는다」 예시가 사는 자리다. 규칙을 적은 문서가 그 규칙을 어긴 것으로
// 잡히지 않게 줄을 통째로 비운다. 줄 번호는 유지한다.
.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 = /(?<![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)) kinds.add('했다');
if (/(아니다|없다|같다|다르다|이다)[.!]?$/.test(st)) kinds.add('이다');
if (/(못한다|않는다|않았다|못했다)[.!]?$/.test(st)) kinds.add('부정');
if (/(뿐이다|때문이다|까지다|것이다)[.!]?$/.test(st)) kinds.add('설명');
if (/[가-힣A-Za-z0-9`)\]]$/.test(st.replace(/[.!]$/, ''))) kinds.add('명사');
if (/까요\??$/.test(st) || /\?$/.test(st)) kinds.add('물음');
if (/(봅시다|보자|맙시다|주세요)[.!]?$/.test(st)) kinds.add('청유');
}
// 3-1. 문장을 「~것이 ~이다」로 닫는 버릇
//
// 사건을 서술하는 대신 그것이 무엇인지 이름 붙이고 끝낸다. 「확인한 자리다」·「~하는 실례다」·
// 「~라는 점이다」. 참고 여섯 편은 이 어미를 통틀어 세 번 쓴다(17386 1 · 22396 2). 한 문서에
// 이것이 몰려 있으면 사건을 적지 않고 분류만 한 것이다.
// 「자리다」·「실례다」·「경우다」는 받침이 없어 `이`가 붙지 않는다. `이다` 만 찾으면 정작
// 가장 나쁜 세 개를 놓친다 — 처음 이 규칙을 넣었을 때 그렇게 됐다.
const namingEnd = [
...text.matchAll(/[가-힣)\]`]\s*(사실|이유|점|것|뿐|셈)(이다|입니다)[.!]?(?=\s|$)/g),
...text.matchAll(/[가-힣)\]`]\s*(자리|실례|경우|출발점|사례|대목)(다|이다|입니다)[.!]?(?=\s|$)/g),
];
const alwaysBad = namingEnd.filter((m) => /(자리|실례|출발점|대목)/.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+\.|:::|<!--|\||>)/.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)}"). 지우다 남은 조각이거나 마침표가 빠졌습니다.` });
}
}
// 독자에게 사고를 지시하는 문장 — 사건으로 바로 들어가면 될 자리
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);