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>
83 lines
3.9 KiB
JavaScript
83 lines
3.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// 글이 얼마나 채워져 있는지 잰다. 문체가 아니라 밀도를 본다.
|
|
//
|
|
// node density.mjs 초안.md [...] 기준값과 나란히 보여 준다
|
|
// node density.mjs --baseline dir/*.md 기준값을 다시 잰다
|
|
//
|
|
// 기준값은 ../examples 의 여섯 편에서 잰 것이다. 맞히려고 문단을 넣지 않는다 —
|
|
// 낮게 나오면 설명이 빠진 자리를 찾으라는 뜻이지 분량을 늘리라는 뜻이 아니다.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { basename } from "node:path";
|
|
|
|
// examples 의 여섯 편에서 잰 값. 23625 는 본문 일부만 받아서 낱말 수 기준에서 뺐다.
|
|
const BASE = {
|
|
words: [1277, 2871], sentWords: [14.2, 18.3], code: [1, 13],
|
|
sections: [4, 9], tableLines: [0, 15],
|
|
};
|
|
|
|
// 수치 개수는 장르가 가른다. 잰 것을 쓰는 글과 구조를 쓰는 글의 기준이 다르다.
|
|
// 측정 글 13569(13) · 23625(18) · 20161(24) · 22396(36)
|
|
// 구조 글 17386(2) · 7835(5)
|
|
const NUMBERS = { 측정: [13, 36], 구조: [2, 12] };
|
|
|
|
const NUM = /\d[\d,.]*\s*(?:ms|초|분|시간|일|년|개월|주|배|%|건|개|줄|번|회|명|자|KB|MB|GB|TB|Gbps|TPS|QPS|만|천|억)/g;
|
|
|
|
function measure(text) {
|
|
// frontmatter 와 주석은 글이 아니다
|
|
let t = text.replace(/^---\n.*?\n---\n/s, "").replace(/<!--.*?-->/gs, "");
|
|
const marked = t.match(/<!-- body:start -->([\s\S]*?)<!-- body:end -->/);
|
|
if (marked) t = marked[1];
|
|
|
|
const code = (t.match(/^```/gm) || []).length / 2;
|
|
const tableLines = (t.match(/^\s*\|/gm) || []).length;
|
|
const sections = (t.match(/^## /gm) || []).length;
|
|
const prose = t.replace(/```[\s\S]*?```/g, "").replace(/^\s*\|.*$/gm, "");
|
|
const words = prose.split(/\s+/).filter(Boolean).length;
|
|
const sentences = prose.split(/(?<=[.!?다])\s+/).filter((s) => s.trim().length > 10);
|
|
const numbers = (t.match(NUM) || []).length;
|
|
const proseParas = prose.split(/\n\s*\n/).filter((p) => p.trim().length > 40).length;
|
|
|
|
return { words, sentWords: +(words / Math.max(sentences.length, 1)).toFixed(1),
|
|
code, numbers, sections, tableLines, proseParas };
|
|
}
|
|
|
|
const args = process.argv.slice(2);
|
|
// 잰 것을 쓴 글인가 구조를 쓴 글인가. Case 는 측정, Concept 은 `--구조` 로 부른다.
|
|
const genre = args.includes("--구조") ? "구조" : "측정";
|
|
const files = args.filter((a) => !a.startsWith("--"));
|
|
|
|
if (args.includes("--baseline")) {
|
|
const all = files.map((f) => measure(readFileSync(f, "utf8")));
|
|
const range = (k) => [Math.min(...all.map((m) => m[k])), Math.max(...all.map((m) => m[k]))];
|
|
for (const k of [...Object.keys(BASE), "numbers"]) console.log(` ${k}: [${range(k)}]`);
|
|
process.exit(0);
|
|
}
|
|
if (!files.length) {
|
|
console.error("쓰는 법: node density.mjs [--구조] 초안.md");
|
|
process.exit(2);
|
|
}
|
|
|
|
const LABEL = { words: "낱말", sentWords: "문장 평균 낱말", code: "코드블록",
|
|
numbers: "수치", sections: "## 절", tableLines: "표 줄" };
|
|
|
|
let bad = 0;
|
|
for (const file of files) {
|
|
const m = measure(readFileSync(file, "utf8"));
|
|
console.log(`\n${basename(file)} (${genre} 글 기준)`);
|
|
for (const [k, [lo, hi]] of Object.entries({ ...BASE, numbers: NUMBERS[genre] })) {
|
|
const v = m[k];
|
|
const ok = v >= lo && v <= hi;
|
|
const note = ok ? "" : v < lo ? " ← 모자람" : " ← 넘침";
|
|
if (!ok && k !== "words" && k !== "code") bad++;
|
|
console.log(` ${LABEL[k].padEnd(16)} ${String(v).padStart(6)} 기준 ${lo}~${hi}${note}`);
|
|
}
|
|
if (m.tableLines > m.proseParas)
|
|
console.log(` ! 표 ${m.tableLines}줄이 산문 ${m.proseParas}문단보다 많다 — 표가 설명을 대신하고 있다`);
|
|
if (m.numbers < NUMBERS[genre][0])
|
|
console.log(` ! 수치가 ${m.numbers}개다 — 자료에 있는 크기를 「여러」·「대부분」으로 뭉갠 자리를 찾는다`);
|
|
if (m.sections > 9)
|
|
console.log(` ! 절이 ${m.sections}개다 — 문단 두 개짜리 절을 앞뒤와 합친다`);
|
|
}
|
|
process.exit(bad ? 1 : 0);
|