The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
15 KiB
JavaScript
242 lines
15 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('청유');
|
|
}
|
|
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);
|