The originating repository's SVGs were drawn by hand and every one of them
put a title, a subtitle and an explanation band inside the canvas. This
repository forbids both, so they could not be carried over — the whole set
was rebuilt through the skill's pipeline instead.
Each diagram went through prepare, references, prompt, a VizSpec 1.1 citing
document line ranges, lint, and render. All 28 pass lint and produce the
same eight formats the existing keycloak project has. Sentences moved out of
the canvas into <desc> and the paragraph beside each figure; the drawings
carry names only.
Two lint rules did real work rather than formatting work:
edge-through-node caught arrows crossing an unrelated
node and implying an adjacency that
does not exist — four diagrams had to
be restructured, not just relaid out
evidence-outside-prepared-context caught a diagram citing another
section; its anchor moved from B-0 to
B-1 so all three sections it draws on
are inside the prepared context
lab-topology also had to change profile: its context offers a different
candidate set, and query-fanout with shard roles is what the section
actually shows — one entry point spreading to two Keycloak nodes.
The document now carries all 28 inline, one per claim that needed one, and
the section recording what was still missing is updated: the diagram gap is
closed, Studio records remain.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
16 KiB
JavaScript
260 lines
16 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 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: /(?<!연관\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)];
|
|
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}부터|이번에는|공유합니다|공유하고자|다루겠습니다|보겠습니다|하겠습니다)/;
|
|
// 강제하지 않는다. 강제했더니 `먼저 ~를 구분해야 합니다` 같은 지도형 문장이 절마다 붙어서
|
|
// 문장이 아니라 구조가 기계처럼 읽히게 됐다.
|
|
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);
|