Some checks are pending
ci / build (push) Waiting to run
Measured convergence before: split hero 13/19, top-bar nav 19/19, one-line notice footer 14/19, body Pretendard 17/21, 10 similarity pairs above 0.78. Each showcase got a pre-assigned hero family, nav form, footer form, type pair, background, accent hue, radius, motion grammar and material from outputs/rework-2026-10-06/plan.md; direction cards and the chosen reason are recorded in every design.md. After: 0 similarity warnings, 19 distinct display faces, 7 hero families, 5 nav forms, 5 footer forms, no scroll reveals, 5 showcases with SVG filter materials. Example contact values replace bracket placeholders under the existing fictional-brand notices. Thumbnails, OG images, card copy and three e2e assertions updated; dead CSS in dure-enrollment and hannam-jari removed; similarity.mjs learns the new font families.
527 lines
20 KiB
JavaScript
527 lines
20 KiB
JavaScript
// L0-수렴 — 쇼케이스 간 시각 지문 유사도 게이트.
|
||
//
|
||
// 왜 필요한가: 기존 게이트는 전부 "한 페이지 안"을 본다. 넘침·대비·토큰 위생은
|
||
// 통과했는데 쇼케이스 21종이 하나의 하우스 룩으로 수렴했다(레드팀 2026-10-01
|
||
// 05·06 실측: 본문 Pretendard 21/21, 표제 마루부리 11/21, radius 2px 11곳,
|
||
// 웜 오프화이트·니어블랙 양극 배경, lumina·pulsegate·synapse 사실상 같은 템플릿).
|
||
// 스킬이 약속한 "다른 클라이언트의 것과 혼동될 수 없다"는 페이지 간 비교로만
|
||
// 검증된다. 이 검사가 그 비교를 한다.
|
||
//
|
||
// 지문 축 (각 public/work/<slug>/styles/*.css 정적 분석, var() 재귀 해석):
|
||
// 1. 한글 본문 서체 — body/--font-body 스택에서 첫 한글 지원 서체
|
||
// 2. 한글 표제 서체 — h1/--font-display 스택에서 첫 한글 지원 서체
|
||
// 3. 배경 — body/--surface 계열 색의 CIELAB 명도·채도·색상
|
||
// 4. 강조색 hue — --accent 계열(무채색이면 다음 후보)
|
||
// 5. radius 구간 — --radius 또는 최빈 border-radius
|
||
// 6. 모노 사용 — 규칙에서 모노 스택을 실제로 쓰는가
|
||
//
|
||
// 판정:
|
||
// - 쌍별 유사도(0~1)가 threshold 이상이면 수렴 쌍. 기본은 WARN.
|
||
// config.strict 에 든 쇼케이스(재작업 대상)가 낀 쌍은 FAIL.
|
||
// - 분포 편중: 한 값이 전체의 maxShare 를 넘으면 WARN (distributionMode: "fail" 이면 FAIL).
|
||
//
|
||
// 사용: node tools/similarity.mjs [--config=similarity.config.json] [--json]
|
||
// 결과: tools/similarity-report.md 에 표를 남긴다.
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import url from "node:url";
|
||
|
||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||
const root = path.resolve(here, "..");
|
||
const workRoot = path.join(root, "public", "work");
|
||
|
||
const configArg = process.argv.find((a) => a.startsWith("--config="));
|
||
const configPath = path.resolve(root, configArg ? configArg.slice("--config=".length) : "similarity.config.json");
|
||
const defaults = {
|
||
threshold: 0.78,
|
||
maxShare: 0.4,
|
||
distributionMode: "warn",
|
||
strict: [],
|
||
ignore: [],
|
||
weights: { body: 0.25, display: 0.2, background: 0.2, accent: 0.15, radius: 0.1, mono: 0.1 },
|
||
};
|
||
const config = fs.existsSync(configPath)
|
||
? { ...defaults, ...JSON.parse(fs.readFileSync(configPath, "utf8")) }
|
||
: defaults;
|
||
config.weights = { ...defaults.weights, ...(config.weights || {}) };
|
||
|
||
/* ── 서체 사전 ── */
|
||
// 한글 글리프를 가진 서체. 스택의 첫 라틴 전용 서체(IBM Plex Sans 등)를 건너뛰고
|
||
// 실제로 한글을 그리는 서체를 찾기 위해 쓴다. 이름은 정규화한 소문자로 비교한다.
|
||
const KOREAN_FONTS = new Map(
|
||
[
|
||
["pretendard", "Pretendard"],
|
||
["pretendard variable", "Pretendard"],
|
||
["pretendard jp", "Pretendard"],
|
||
["suit", "SUIT"],
|
||
["suit variable", "SUIT"],
|
||
["wanted sans", "Wanted Sans"],
|
||
["wanted sans variable", "Wanted Sans"],
|
||
["ibm plex sans kr", "IBM Plex Sans KR"],
|
||
["noto sans kr", "Noto Sans KR"],
|
||
["noto serif kr", "Noto Serif KR"],
|
||
["spoqa han sans neo", "Spoqa Han Sans Neo"],
|
||
["gothic a1", "Gothic A1"],
|
||
["nanum gothic", "Nanum Gothic"],
|
||
["nanumgothic", "Nanum Gothic"],
|
||
["nanum myeongjo", "Nanum Myeongjo"],
|
||
["nanum gothic coding", "Nanum Gothic Coding"],
|
||
["maruburi", "MaruBuri"],
|
||
["maru buri", "MaruBuri"],
|
||
["ridibatang", "RIDIBatang"],
|
||
["ridi batang", "RIDIBatang"],
|
||
["iropke batang", "Iropke Batang"],
|
||
["iropkebatang", "Iropke Batang"],
|
||
["orbit", "Orbit"],
|
||
["gasoek one", "Gasoek One"],
|
||
["kopubworld batang", "KoPub Batang"],
|
||
["gowun batang", "Gowun Batang"],
|
||
["gowun dodum", "Gowun Dodum"],
|
||
["hahmlet", "Hahmlet"],
|
||
["song myung", "Song Myung"],
|
||
["do hyeon", "Do Hyeon"],
|
||
["black han sans", "Black Han Sans"],
|
||
["jua", "Jua"],
|
||
["sunflower", "Sunflower"],
|
||
["orbit", "Orbit"],
|
||
["gasoek one", "Gasoek One"],
|
||
["bagel fat one", "Bagel Fat One"],
|
||
["dongle", "Dongle"],
|
||
["s-core dream", "S-Core Dream"],
|
||
["gmarket sans", "Gmarket Sans"],
|
||
["jeju myeongjo", "Jeju Myeongjo"],
|
||
["apple sd gothic neo", "Apple SD Gothic Neo"],
|
||
["malgun gothic", "Malgun Gothic"],
|
||
],
|
||
);
|
||
const MONO_RE = /\b(mono|monospace|consolas|courier|menlo|sfmono|coding)\b/i;
|
||
|
||
/* ── CSS 파싱 ── */
|
||
function stripComments(css) {
|
||
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
||
}
|
||
|
||
/** 최상위·미디어 안의 규칙을 평평하게 뽑는다. @font-face 는 지문이 아니므로 버린다. */
|
||
function rulesOf(css) {
|
||
const rules = [];
|
||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||
let m;
|
||
while ((m = re.exec(css))) {
|
||
const selector = m[1].trim().replace(/^@media[^{]*$/, "").trim();
|
||
if (!selector || selector.startsWith("@font-face")) continue;
|
||
rules.push({ selector: selector.replace(/^.*\{/, "").trim(), body: m[2], index: m.index });
|
||
}
|
||
return rules;
|
||
}
|
||
|
||
function declsOf(body) {
|
||
const out = new Map();
|
||
for (const part of body.split(";")) {
|
||
const i = part.indexOf(":");
|
||
if (i < 0) continue;
|
||
const prop = part.slice(0, i).trim().toLowerCase();
|
||
const value = part.slice(i + 1).trim();
|
||
if (prop && value && !out.has(prop)) out.set(prop, value);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function readShowcase(slug) {
|
||
const stylesDir = path.join(workRoot, slug, "styles");
|
||
if (!fs.existsSync(stylesDir)) return null;
|
||
const order = ["tokens.css", "fonts.css", "main.css", "site.css", "landing.css"];
|
||
const files = fs
|
||
.readdirSync(stylesDir)
|
||
.filter((f) => f.endsWith(".css"))
|
||
.sort((a, b) => {
|
||
const ia = order.indexOf(a);
|
||
const ib = order.indexOf(b);
|
||
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b);
|
||
});
|
||
const css = files.map((f) => stripComments(fs.readFileSync(path.join(stylesDir, f), "utf8"))).join("\n");
|
||
return css;
|
||
}
|
||
|
||
/** :root·html 에서 처음 정의된 값을 쓴다. 다크 모드 재정의는 기본 지문이 아니다. */
|
||
function collectVars(rules) {
|
||
const vars = new Map();
|
||
for (const r of rules) {
|
||
if (!/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selector)) continue;
|
||
for (const [prop, value] of declsOf(r.body)) {
|
||
if (prop.startsWith("--") && !vars.has(prop)) vars.set(prop, value);
|
||
}
|
||
}
|
||
// :root 밖에서만 정의된 토큰도 마지막 후보로 받는다.
|
||
for (const r of rules) {
|
||
for (const [prop, value] of declsOf(r.body)) {
|
||
if (prop.startsWith("--") && !vars.has(prop)) vars.set(prop, value);
|
||
}
|
||
}
|
||
return vars;
|
||
}
|
||
|
||
function resolve(value, vars, depth = 0) {
|
||
if (value == null || depth > 8) return value;
|
||
return value.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*(?:\([^()]*\)[^()]*)*))?\)/g, (_, name, fallback) => {
|
||
const v = vars.get(name);
|
||
if (v != null) return resolve(v, vars, depth + 1);
|
||
return fallback != null ? resolve(fallback.trim(), vars, depth + 1) : "";
|
||
});
|
||
}
|
||
|
||
function familiesOf(stack) {
|
||
return String(stack || "")
|
||
.split(",")
|
||
.map((f) => f.trim().replace(/^["']|["']$/g, "").trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function koreanFamily(stack) {
|
||
for (const f of familiesOf(stack)) {
|
||
const key = f.toLowerCase();
|
||
if (KOREAN_FONTS.has(key)) return KOREAN_FONTS.get(key);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* ── 색 ── */
|
||
const NAMED = { white: "#ffffff", black: "#000000" };
|
||
function parseColor(raw) {
|
||
if (!raw) return null;
|
||
let s = raw.trim().toLowerCase();
|
||
if (NAMED[s]) s = NAMED[s];
|
||
let m = s.match(/^#([0-9a-f]{3,8})\b/);
|
||
if (m) {
|
||
let h = m[1];
|
||
if (h.length === 3 || h.length === 4) h = h.slice(0, 3).split("").map((c) => c + c).join("");
|
||
if (h.length < 6) return null;
|
||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
||
}
|
||
m = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/);
|
||
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||
m = s.match(/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/);
|
||
if (m) {
|
||
const h = Number(m[1]) / 360;
|
||
const sat = Number(m[2]) / 100;
|
||
const l = Number(m[3]) / 100;
|
||
const q = l < 0.5 ? l * (1 + sat) : l + sat - l * sat;
|
||
const p = 2 * l - q;
|
||
const f = (t) => {
|
||
t = (t + 1) % 1;
|
||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||
if (t < 1 / 2) return q;
|
||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||
return p;
|
||
};
|
||
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
|
||
}
|
||
// 여러 값이 섞인 background 단축 속성에서 첫 색을 찾는다.
|
||
m = s.match(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/);
|
||
if (m && m[0] !== s) return parseColor(m[0]);
|
||
return null;
|
||
}
|
||
|
||
/** sRGB → CIELAB(D65) → LCh. 명도·채도·색상을 사람 눈 기준으로 비교하기 위해 쓴다. */
|
||
export function lch(rgb) {
|
||
const lin = rgb.map((v) => {
|
||
const c = v / 255;
|
||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||
});
|
||
const x = (lin[0] * 0.4124 + lin[1] * 0.3576 + lin[2] * 0.1805) / 0.95047;
|
||
const y = lin[0] * 0.2126 + lin[1] * 0.7152 + lin[2] * 0.0722;
|
||
const z = (lin[0] * 0.0193 + lin[1] * 0.1192 + lin[2] * 0.9505) / 1.08883;
|
||
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
|
||
const L = 116 * f(y) - 16;
|
||
const a = 500 * (f(x) - f(y));
|
||
const b = 200 * (f(y) - f(z));
|
||
const C = Math.hypot(a, b);
|
||
const h = ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360;
|
||
return { L, C, h, a, b };
|
||
}
|
||
|
||
function hueDelta(a, b) {
|
||
const d = Math.abs(a - b) % 360;
|
||
return d > 180 ? 360 - d : d;
|
||
}
|
||
|
||
/* ── 길이 ── */
|
||
function toPx(raw) {
|
||
if (raw == null) return null;
|
||
const s = String(raw).trim();
|
||
if (s === "0") return 0;
|
||
const m = s.match(/^(-?[\d.]+)(px|rem|em)?/);
|
||
if (!m) return null;
|
||
const n = Number(m[1]);
|
||
return m[2] === "rem" || m[2] === "em" ? n * 16 : n;
|
||
}
|
||
|
||
function radiusBucket(px) {
|
||
if (px == null) return "미상";
|
||
if (px === 0) return "0";
|
||
if (px <= 3) return "1–3px";
|
||
if (px <= 8) return "4–8px";
|
||
if (px <= 16) return "9–16px";
|
||
if (px < 999) return "17px+";
|
||
return "pill";
|
||
}
|
||
|
||
/* ── 지문 추출 ── */
|
||
function firstDecl(rules, selectorRe, prop) {
|
||
for (const r of rules) {
|
||
if (!selectorRe.test(r.selector)) continue;
|
||
const v = declsOf(r.body).get(prop);
|
||
if (v) return v;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function firstVar(vars, names) {
|
||
for (const n of names) if (vars.has(n)) return { name: n, value: vars.get(n) };
|
||
return null;
|
||
}
|
||
|
||
export function fingerprint(slug, css) {
|
||
const rules = rulesOf(css);
|
||
const vars = collectVars(rules);
|
||
const res = (v) => resolve(v, vars);
|
||
|
||
const bodyStack = res(
|
||
firstDecl(rules, /(^|,)\s*body\s*(,|$)/, "font-family") ||
|
||
firstVar(vars, ["--font-body", "--font-sans", "--font-text", "--font-base"])?.value ||
|
||
firstDecl(rules, /(^|,)\s*html\s*(,|$)/, "font-family") ||
|
||
"",
|
||
);
|
||
const displayStack = res(
|
||
firstDecl(rules, /(^|[\s,>])h1(\b|$)/, "font-family") ||
|
||
firstVar(vars, ["--font-display", "--font-head", "--font-heading", "--font-serif"])?.value ||
|
||
bodyStack,
|
||
);
|
||
|
||
const bgRaw = res(
|
||
firstDecl(rules, /(^|,)\s*body\s*(,|$)/, "background-color") ||
|
||
firstDecl(rules, /(^|,)\s*body\s*(,|$)/, "background") ||
|
||
firstVar(vars, ["--bg", "--surface", "--paper", "--page", "--background", "--color-bg"])?.value ||
|
||
"",
|
||
);
|
||
const bgRgb = parseColor(bgRaw);
|
||
|
||
let accent = null;
|
||
for (const name of ["--accent", "--brand", "--signal", "--primary", "--accent-stroke", "--cobalt", "--highlight"]) {
|
||
if (!vars.has(name)) continue;
|
||
const rgb = parseColor(res(vars.get(name)));
|
||
if (!rgb) continue;
|
||
const c = lch(rgb);
|
||
if (c.C < 12) continue; // 무채색 primary(#18181b 등)는 강조색이 아니다.
|
||
accent = { name, rgb, ...c };
|
||
break;
|
||
}
|
||
if (!accent) {
|
||
// 이름 규약이 다른 쇼케이스(--blue, --gold 등)는 :root 색 토큰 중 채도가 가장 높은
|
||
// 것을 강조색으로 본다. 상태색·옅은 면색은 브랜드 강조가 아니므로 뺀다.
|
||
for (const [name, value] of vars) {
|
||
if (/pale|soft|wash|tint|bg|surface|paper|page|ink|line|danger|error|success|warn|focus|review/.test(name)) continue;
|
||
const rgb = parseColor(res(value));
|
||
if (!rgb) continue;
|
||
const c = lch(rgb);
|
||
if (c.C < 12) continue;
|
||
if (!accent || c.C > accent.C) accent = { name, rgb, ...c };
|
||
}
|
||
}
|
||
|
||
let radiusPx = toPx(res(vars.get("--radius") ?? ""));
|
||
if (radiusPx == null) {
|
||
const counts = new Map();
|
||
for (const m of css.matchAll(/border-radius\s*:\s*([^;}]+)/g)) {
|
||
const first = res(m[1]).trim().split(/\s+/)[0];
|
||
if (/%$/.test(first)) continue; // 원형(50%)은 모서리 언어가 아니라 도형이다.
|
||
const px = toPx(first);
|
||
if (px == null || px >= 999) continue;
|
||
counts.set(px, (counts.get(px) || 0) + 1);
|
||
}
|
||
radiusPx = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
||
}
|
||
|
||
// 모노: 모노 스택이 :root 밖의 실제 규칙에서 font-family 로 쓰이는가.
|
||
let mono = false;
|
||
for (const r of rules) {
|
||
if (/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selector)) continue;
|
||
const ff = declsOf(r.body).get("font-family") || declsOf(r.body).get("font");
|
||
if (!ff) continue;
|
||
const first = familiesOf(res(ff))[0] || "";
|
||
if (MONO_RE.test(first)) {
|
||
mono = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const bg = bgRgb ? lch(bgRgb) : null;
|
||
return {
|
||
slug,
|
||
body: koreanFamily(bodyStack) || familiesOf(bodyStack)[0] || "미상",
|
||
display: koreanFamily(displayStack) || familiesOf(displayStack)[0] || "미상",
|
||
bg: bg ? { hex: toHex(bgRgb), ...round(bg) } : null,
|
||
bgClass: bg ? bgClass(bg) : "미상",
|
||
accent: accent ? { hex: toHex(accent.rgb), ...round(accent), name: accent.name } : null,
|
||
radiusPx,
|
||
radius: radiusBucket(radiusPx),
|
||
mono,
|
||
};
|
||
}
|
||
|
||
function toHex(rgb) {
|
||
return "#" + rgb.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
|
||
}
|
||
function round(c) {
|
||
return { L: Math.round(c.L), C: Math.round(c.C), h: Math.round(c.h) };
|
||
}
|
||
function bgClass(c) {
|
||
const tone = c.L < 22 ? "어두움" : c.L > 88 ? "밝음" : "중간";
|
||
const chroma = c.C < 3 ? "무채" : c.C < 15 ? "웜/쿨 기운" : "유채";
|
||
return `${tone}·${chroma}`;
|
||
}
|
||
|
||
/* ── 유사도 ── */
|
||
export function similarity(a, b, weights = defaults.weights) {
|
||
const parts = {};
|
||
parts.body = a.body === b.body ? 1 : 0;
|
||
parts.display = a.display === b.display ? 1 : 0;
|
||
if (a.bg && b.bg) {
|
||
const dE = Math.hypot(a.bg.L - b.bg.L, (a.bg.C - b.bg.C) * 1.5, a.bg.C > 6 && b.bg.C > 6 ? hueDelta(a.bg.h, b.bg.h) / 6 : 0);
|
||
parts.background = Math.max(0, 1 - dE / 30);
|
||
} else parts.background = 0;
|
||
if (a.accent && b.accent) parts.accent = Math.max(0, 1 - hueDelta(a.accent.h, b.accent.h) / 50);
|
||
else parts.accent = !a.accent && !b.accent ? 1 : 0;
|
||
parts.radius = a.radius === b.radius ? 1 : 0;
|
||
parts.mono = a.mono === b.mono ? 1 : 0;
|
||
let score = 0;
|
||
let total = 0;
|
||
for (const [k, w] of Object.entries(weights)) {
|
||
score += (parts[k] ?? 0) * w;
|
||
total += w;
|
||
}
|
||
return { score: total ? score / total : 0, parts };
|
||
}
|
||
|
||
/* ── 실행 ── */
|
||
function main() {
|
||
const slugs = fs
|
||
.readdirSync(workRoot, { withFileTypes: true })
|
||
.filter((d) => d.isDirectory() && !config.ignore.includes(d.name))
|
||
.map((d) => d.name)
|
||
.sort();
|
||
|
||
const prints = [];
|
||
for (const slug of slugs) {
|
||
const css = readShowcase(slug);
|
||
if (css == null) continue;
|
||
prints.push(fingerprint(slug, css));
|
||
}
|
||
|
||
const strict = new Set(config.strict);
|
||
const pairs = [];
|
||
for (let i = 0; i < prints.length; i++) {
|
||
for (let j = i + 1; j < prints.length; j++) {
|
||
const { score, parts } = similarity(prints[i], prints[j], config.weights);
|
||
pairs.push({ a: prints[i].slug, b: prints[j].slug, score, parts });
|
||
}
|
||
}
|
||
pairs.sort((x, y) => y.score - x.score);
|
||
|
||
const failures = [];
|
||
const warnings = [];
|
||
let pass = 0;
|
||
for (const p of pairs) {
|
||
if (p.score < config.threshold) {
|
||
pass++;
|
||
continue;
|
||
}
|
||
const same = Object.entries(p.parts)
|
||
.filter(([, v]) => v >= 0.75)
|
||
.map(([k]) => k)
|
||
.join("·");
|
||
const line = `${p.a} ↔ ${p.b} 유사도 ${p.score.toFixed(2)} ≥ ${config.threshold} (겹친 축: ${same})`;
|
||
if (strict.has(p.a) || strict.has(p.b)) failures.push(line);
|
||
else warnings.push(line);
|
||
}
|
||
|
||
const axes = [
|
||
["body", "한글 본문 서체", (f) => f.body],
|
||
["display", "한글 표제 서체", (f) => f.display],
|
||
["bgClass", "배경 명도·채도", (f) => f.bgClass],
|
||
["radius", "radius 구간", (f) => f.radius],
|
||
["mono", "모노 사용", (f) => (f.mono ? "사용" : "미사용")],
|
||
];
|
||
const distribution = [];
|
||
for (const [key, label, pick] of axes) {
|
||
const counts = new Map();
|
||
for (const f of prints) counts.set(pick(f), (counts.get(pick(f)) || 0) + 1);
|
||
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||
const [top, n] = sorted[0] || ["", 0];
|
||
const share = prints.length ? n / prints.length : 0;
|
||
// 모노 미사용은 편중이어도 문제가 아니다 — 수렴은 '같은 장치를 다 같이 쓰는 것'이다.
|
||
const skip = key === "mono" && top === "미사용";
|
||
const over = !skip && share > config.maxShare;
|
||
distribution.push({ key, label, top, n, share, over, sorted });
|
||
const line = `${label} 편중 — '${top}' ${n}/${prints.length} (${Math.round(share * 100)}%) > ${Math.round(config.maxShare * 100)}%`;
|
||
if (over) (config.distributionMode === "fail" ? failures : warnings).push(line);
|
||
else pass++;
|
||
}
|
||
|
||
if (process.argv.includes("--json")) {
|
||
console.log(JSON.stringify({ prints, pairs: pairs.slice(0, 30), distribution }, null, 2));
|
||
}
|
||
|
||
writeReport(prints, pairs, distribution, warnings, failures);
|
||
|
||
console.log(`쇼케이스 유사도 — ${prints.length}종 · 쌍 ${pairs.length}개 · 임계 ${config.threshold}`);
|
||
for (const w of warnings) console.log(" WARN " + w);
|
||
for (const f of failures) console.log(" FAIL " + f);
|
||
// verify.mjs 는 'PASS n / FAIL m' 요약을 센다. 통과면 FAIL 낱말을 쓰지 않는다.
|
||
if (failures.length) {
|
||
console.log(`✗ PASS ${pass} / FAIL ${failures.length} (경고 ${warnings.length})`);
|
||
process.exit(1);
|
||
}
|
||
console.log(`✓ PASS ${pass}개 검사 통과 (경고 ${warnings.length})`);
|
||
}
|
||
|
||
function writeReport(prints, pairs, distribution, warnings, failures) {
|
||
const pct = (x) => `${Math.round(x * 100)}%`;
|
||
const md = [
|
||
`# 쇼케이스 유사도 리포트`,
|
||
``,
|
||
`생성: \`node tools/similarity.mjs\` · 임계 ${config.threshold} · 분포 상한 ${pct(config.maxShare)} · 재작업 엄격 대상: ${config.strict.join(", ") || "없음"}`,
|
||
``,
|
||
`결과: 실패 ${failures.length} · 경고 ${warnings.length}`,
|
||
``,
|
||
`## 지문`,
|
||
``,
|
||
`| 쇼케이스 | 한글 본문 | 한글 표제 | 배경 | 배경 L·C·h | 강조 hue | radius | 모노 |`,
|
||
`|---|---|---|---|---|---|---|---|`,
|
||
...prints.map(
|
||
(f) =>
|
||
`| ${f.slug} | ${f.body} | ${f.display} | ${f.bg?.hex ?? "—"} ${f.bgClass} | ${f.bg ? `${f.bg.L}·${f.bg.C}·${f.bg.h}` : "—"} | ${f.accent ? `${f.accent.hex} (${f.accent.h}°)` : "—"} | ${f.radiusPx ?? "—"} (${f.radius}) | ${f.mono ? "사용" : "—"} |`,
|
||
),
|
||
``,
|
||
`## 분포`,
|
||
``,
|
||
`| 축 | 최빈값 | 점유 | 상한 초과 | 전체 |`,
|
||
`|---|---|---|---|---|`,
|
||
...distribution.map((d) => `| ${d.label} | ${d.top} | ${d.n}/${prints.length} (${pct(d.share)}) | ${d.over ? "**초과**" : "—"} | ${d.sorted.map(([v, n]) => `${v} ${n}`).join(", ")} |`),
|
||
``,
|
||
`## 유사도 상위 15쌍`,
|
||
``,
|
||
`| 쌍 | 점수 | 본문 | 표제 | 배경 | 강조 | radius | 모노 |`,
|
||
`|---|---|---|---|---|---|---|---|`,
|
||
...pairs.slice(0, 15).map(
|
||
(p) =>
|
||
`| ${p.a} ↔ ${p.b} | ${p.score.toFixed(2)} | ${p.parts.body} | ${p.parts.display} | ${p.parts.background.toFixed(2)} | ${p.parts.accent.toFixed(2)} | ${p.parts.radius} | ${p.parts.mono} |`,
|
||
),
|
||
``,
|
||
...(warnings.length ? [`## 경고`, ``, ...warnings.map((w) => `- ${w}`), ``] : []),
|
||
...(failures.length ? [`## 실패`, ``, ...failures.map((w) => `- ${w}`), ``] : []),
|
||
].join("\n");
|
||
fs.writeFileSync(path.join(here, "similarity-report.md"), md);
|
||
}
|
||
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === url.fileURLToPath(import.meta.url)) main();
|