designpaca/packages/skill/tools/design-gate.mjs
Yun Chan 88c11682b1 feat(skill): route fixes, delivery report, composition diversity, platform refresh
- route table: extension route for products with an existing visual
  language, feedback row, derived state tokens stay local
- completion: blocking 0 plus reported unverified; hard-gate surface is
  the touched scope; delivery report starts with what and how to open
- design.md created at step 0 with a skeleton, fingerprint line and
  change log; product-surface rubric reading; direction delegation
  states the chosen card and reason
- layout: hero families, nav forms, footer forms; art-direction page
  convergence 6 axes; genre sections; image treatments; placeholder
  conventions; multi-screen order; Tailwind theme mapping
- platform: scroll-driven examples, anchor positioning, canvas OT,
  three/R3F pins, shadcn style prefixes, Safari 26 notes, subgrid
- design-gate: four result states, exit codes, cwd module resolution,
  --url/--out-dir/--strict, oklch contrast, dead selector word match
- Korean font budget split from the Latin baseline
2026-10-06 16:55:38 +09:00

1247 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구)
//
// 조사 기반 7계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션 + 죽은 선택자·토큰 위생) + L2(불변식·SEO/meta·theme-color 정합·터치타깃·런타임 오류) + L3(시각회귀·옵션) + L4(WebKit·옵션).
// 프로젝트 고유의 L1(단위)·L5(탐색)·L6(시나리오 E2E)는 각 프로젝트의 calc/테스트로 보강한다.
//
// 사용법·옵션·설정 키·종료 코드는 `node design-gate.mjs --help` 가 정본이다(아래 HELP).
//
// 철학: 결과는 넷이다 — PASS(쟀고 충족) / FAIL(쟀고 미달) / 미검증(도구·입력이 없어 못 쟀다) / OFF(설정으로 껐다).
// 미검증은 통과가 아니다. 예전에는 못 잰 검사를 "PASS … SKIP"으로 찍고 "전 항목 통과"라고 썼다(실측:
// axe-core 가 없는 프로젝트의 접근성 검사가 통과로 보고됐다). 지금은 따로 세고, 하나라도 있으면
// 리포트 머리말이 "검증 미완료"다.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import url from "node:url";
const HELP = `design-gate — 디자인 QA 게이트
사용법
node design-gate.mjs [옵션] cwd 의 gate.config.json 으로 실행
node design-gate.mjs --url <주소> [--url …] 설정 파일 없이 떠 있는 주소를 검사(읽기 전용)
node design-gate.mjs --init [--force] 설정 초안 생성(있으면 덮어쓰지 않는다)
옵션 (값은 "--키 값" 과 "--키=값" 둘 다 받는다)
--config <파일> 설정 파일(기본 gate.config.json). --init 과 같이 쓰면 그 경로에 초안을 쓴다
--url <주소> 검사할 주소. 여러 번 줄 수 있다. --config 없이 쓰면 설정 파일을 읽지 않고 기본값으로 돈다.
--out-dir 이 없으면 리포트도 쓰지 않는다 — 프로젝트 디렉터리에 아무것도 남기지 않는다
--out-dir <경로> 리포트(gate-report.md)와 시각 기준 화면(__gate__/baseline)을 쓰는 위치(기본 cwd)
--no-report 리포트 파일을 쓰지 않는다(결과는 표준 출력으로만)
--strict 미검증이 하나라도 있으면 exit 3
--static-only 브라우저를 띄우지 않고 L0 정적 검사만 돈다(브라우저 계층은 미검증으로 보고)
--update-baseline 시각 기준 화면 갱신(검증된 배포 후에만)
--force --init 이 기존 설정을 덮어쓰게 한다
--help, -h 이 도움말
결과 상태
PASS 쟀고 충족 FAIL 쟀고 미달
미검증 도구·입력이 없어 못 쟀다(통과로 세지 않는다)
OFF 설정으로 끈 검사(집계에서 뺀다)
종료 코드
0 실패 0건(미검증이 있으면 머리말이 "검증 미완료"다)
1 실패 1건 이상
2 사용법·설정 오류(모르는 인자, 설정 파일 없음·형식 오류, --init 덮어쓰기 거부)
3 --strict 인데 미검증이 있다(실패는 0건)
선택 도구 — 실행한 디렉터리(cwd)의 node_modules → 이 스크립트 위치 순으로 찾는다
puppeteer-core 브라우저 계층 전체. 없으면 브라우저 계층이 미검증이다
axe-core checks.axe. 없으면 axe 미검증
pixelmatch·pngjs checks.visual playwright checks.webkit
stylelint·html-validate checks.static(cwd 쪽만 찾는다)
브라우저 — CHROME_PATH → 설정 chromePath → Chrome·Chromium·Edge 기본 설치 경로 → PATH 순으로 찾는다
설정 키 (gate.config.json)
pages[] name, path(로컬 HTML) 또는 url(떠 있는 주소) — 둘 중 하나는 필수, 같이 줄 수도 있다
h1 제목 선택자(기본 "h1") h1MaxLines 줄 수 계약(양의 정수일 때만 검사)
brand 브랜드 선택자(있을 때만 줄바꿈 검사)
viewAttribute 뷰 전환 버튼의 속성 이름(예: data-view)
views 뷰 이름 목록. 버튼 [viewAttribute="이름"] 을 눌러 전환한다. 버튼이 없으면 실패다
staticContract selectorFiles·selectorDocuments·hygieneFiles·tokenFiles·budgets{colorLiterals,radiusLiterals}
widths 가로 오버플로를 재는 폭 목록(기본 320·390·768·1440·1920·2560)
fontScales 글꼴 확대 배율(기본 1·1.3, 375·390 폭에서 잰다)
checks meta contrast stack wrap rhythm scaleMatrix tapTargets leftInset runtime
deadCss cssHygiene (기본 켜짐) · static visual webkit (기본 꺼짐) · axe("auto"|false)
thresholds contrastNormal 4.5 · contrastLarge 3 · stackLines 3 · radiusLiterals 3
tapTargetMin 44 · inlineTargetMin 24 · leftInsetMin 12 · visualDiffPct 0.1
tapTargetPolicy "contract"(44/24px 미달도 실패, 기본) | "wcag"(WCAG 2.5.8 만 실패)
tapTargetsInline 인라인 링크로 볼 선택자 목록 deadCssIgnore 동적 클래스 접두사 목록
load { waitUntil: "networkidle0", timeoutMs: 30000 }
chromePath 브라우저 실행 파일 chromeArgs 추가 실행 인자 목록(예: ["--no-sandbox"])
strict true 면 --strict 와 같다
`;
// ── 인자 ──
const usageError = (message) => {
console.error(`design-gate: ${message}\n사용법은 --help 로 확인한다.`);
process.exit(2);
};
const FLAG_NAMES = new Set(["--init", "--force", "--strict", "--static-only", "--no-report", "--update-baseline", "--help", "-h"]);
const VALUE_NAMES = new Set(["--config", "--url", "--out-dir"]);
const opt = { flags: new Set(), config: null, urls: [], outDir: null };
{
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (FLAG_NAMES.has(arg)) { opt.flags.add(arg); continue; }
const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
const key = eq > 0 ? arg.slice(0, eq) : arg;
// 모르는 인자를 조용히 넘기면 "--config other.json"(공백형)이 무시되고 기본 설정이 돈다(실측)
if (!VALUE_NAMES.has(key)) usageError(`모르는 인자: ${arg}`);
const value = eq > 0 ? arg.slice(eq + 1) : argv[++i];
if (value === undefined || value === "" || (eq < 0 && value.startsWith("--"))) usageError(`${key} 에 값이 필요하다`);
if (key === "--config") opt.config = value;
else if (key === "--out-dir") opt.outDir = value;
else opt.urls.push(value);
}
}
if (opt.flags.has("--help") || opt.flags.has("-h")) {
console.log(HELP);
process.exit(0);
}
const CWD = process.cwd();
const SCRIPT_DIR = path.dirname(url.fileURLToPath(import.meta.url));
const cfgFile = path.resolve(CWD, opt.config || "gate.config.json");
const DEFAULT_CFG = {
// 뷰 전환(viewAttribute·views)과 brand 는 기본값에 넣지 않는다. 그런 버튼·요소가 없는 일반 페이지에서
// 없는 것을 가정하면 같은 화면을 여러 번 재고 "뷰 n개 검증"이라고 보고하게 된다(실측).
pages: [{ name: "app", path: "index.html", h1: "h1" }],
// preflight W1 의 기본 매트릭스와 같은 목록이다. 한쪽만 고치지 않는다.
widths: [320, 390, 768, 1440, 1920, 2560],
fontScales: [1, 1.3],
thresholds: { contrastNormal: 4.5, contrastLarge: 3, visualDiffPct: 0.1, stackLines: 3, radiusLiterals: 3, tapTargetMin: 44, inlineTargetMin: 24, leftInsetMin: 12 },
checks: { meta: true, contrast: true, stack: true, wrap: true, rhythm: true, scaleMatrix: true, tapTargets: true, leftInset: true, runtime: true, static: false, visual: false, webkit: false, deadCss: true, cssHygiene: true, axe: "auto" },
// "contract"(기본 — 컨트롤 <44px·인라인 <24px 실패) | "wcag"(WCAG 2.5.8 간격 예외 실패만 하드 게이트로 본다, 계약 미달은 참고로만 남긴다)
tapTargetPolicy: "contract",
deadCssIgnore: ["is-"], // 동적으로 조립되는 클래스 접두사(is-${type} 등). 오탐 방지 — 프로젝트마다 추가한다
load: { waitUntil: "networkidle0", timeoutMs: 30000 },
chromePath: null,
};
if (opt.flags.has("--init")) {
// 예전에는 묻지 않고 덮어써서 두 번째 실행에서 뷰 목록·staticContract·예산이 사라졌다(실측)
if (fs.existsSync(cfgFile) && !opt.flags.has("--force")) {
console.error(`design-gate: ${path.relative(CWD, cfgFile) || cfgFile} 이 이미 있다 — 덮어쓰지 않았다. 새 초안으로 바꾸려면 --init --force 를 쓴다.`);
process.exit(2);
}
fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
fs.writeFileSync(cfgFile, JSON.stringify(DEFAULT_CFG, null, 2) + "\n");
console.log(`${path.relative(CWD, cfgFile) || cfgFile} 초안 생성 — pages 를 프로젝트에 맞게 고친다.`);
console.log("빌드 결과를 검사하려면 pages[].url 에 떠 있는 주소를, 뷰 전환이 있으면 viewAttribute·views 를, 브랜드 줄바꿈을 보려면 brand 선택자를 적는다.");
console.log("설정 키·옵션·종료 코드 전체는 --help 에 있다.");
process.exit(0);
}
// ── 설정 ──
const URL_MODE = opt.urls.length > 0;
let _user = {};
if (opt.config || !URL_MODE) {
if (!fs.existsSync(cfgFile)) {
console.error(`design-gate: 설정 파일을 찾지 못했다 — ${cfgFile}\n--init 으로 초안을 만들거나, --config <파일> 또는 --url <주소> 를 준다.`);
process.exit(2);
}
try { _user = JSON.parse(fs.readFileSync(cfgFile, "utf8")); }
catch (e) {
console.error(`design-gate: 설정 파일을 읽지 못했다 — ${cfgFile}: ${e.message}`);
process.exit(2);
}
if (!_user || typeof _user !== "object" || Array.isArray(_user)) {
console.error(`design-gate: 설정 파일은 JSON 객체여야 한다 — ${cfgFile}`);
process.exit(2);
}
}
// checks·thresholds 는 깊은 병합 — 새 검사·임계값이 기본 켜지도록(실측: 프로젝트 구형 설정이 새 기본값을 지우고 "≥undefinedpx"를 찍었다)
const CFG = {
...DEFAULT_CFG,
..._user,
checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) },
thresholds: { ...DEFAULT_CFG.thresholds, ...(_user.thresholds || {}) },
load: { ...DEFAULT_CFG.load, ...(_user.load || {}) },
};
if (URL_MODE) CFG.pages = opt.urls.map((u, i) => ({ name: `url${i + 1}`, url: u, h1: "h1" }));
if (!Array.isArray(CFG.pages) || !CFG.pages.length) {
console.error("gate.config 오류: pages 가 비어 있다.");
process.exit(2);
}
CFG.pages = CFG.pages.map((p, i) => ({ ...p, name: p && p.name ? String(p.name) : `page${i + 1}` }));
// path·url 둘 다 없는 페이지는 렌더도 파일 검사도 대상이 없다 — 실행 전에 명확한 설정 오류로 끊는다
for (const p of CFG.pages) {
if (!p.path && !p.url) {
console.error(`gate.config 오류: pages 항목(name=${p.name || "?"}) 에 path 또는 url 이 필요합니다.`);
process.exit(2);
}
}
if (!Array.isArray(CFG.widths) || !CFG.widths.length || CFG.widths.some((w) => !Number.isFinite(w) || w <= 0)) {
console.error("gate.config 오류: widths 는 양수 폭의 목록이어야 한다.");
process.exit(2);
}
const UPDATE = opt.flags.has("--update-baseline");
const STRICT = opt.flags.has("--strict") || CFG.strict === true;
const STATIC_ONLY = opt.flags.has("--static-only");
const OUT_DIR = opt.outDir ? path.resolve(CWD, opt.outDir) : CWD;
// --url 만 준 실행은 리뷰 경로의 읽기 전용 실행이다. 쓸 곳(--out-dir)을 받지 않았으면 리포트도 쓰지 않는다.
const WRITE_REPORT = !opt.flags.has("--no-report") && !(URL_MODE && !opt.outDir);
const results = [];
const record = (state) => (name, detail = "") => results.push({ state, name, detail });
const pass = record("pass");
const fail = record("fail");
const unverified = record("unverified"); // 도구·입력이 없어 못 쟀다. 통과로 세지 않는다
const off = record("off"); // 설정으로 껐다. 집계에서 뺀다
const errText = (e) => String((e && e.message) || e).split("\n")[0].slice(0, 200);
if (URL_MODE) for (const p of CFG.pages) console.log(`${p.name} = ${p.url.length > 100 ? p.url.slice(0, 100) + "…" : p.url}`);
// ── 선택 도구 해석 ──
// ESM 의 맨 이름 import 는 "이 스크립트 파일 위치"에서 node_modules 를 찾는다. 스킬이 사용자 홈에
// 설치돼 있으면 프로젝트에 `puppeteer-core` 를 설치해도 게이트가 찾지 못했다(실측). 모든 선택 도구를
// 실행한 디렉터리(cwd) → 스크립트 위치 순으로 직접 찾아서 불러온다.
const packageDirFrom = (start, name) => {
for (let dir = start; ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", name);
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
if (path.dirname(dir) === dir) return null;
}
};
const pickExport = (entry) => {
if (typeof entry === "string") return entry;
if (Array.isArray(entry)) {
for (const item of entry) { const found = pickExport(item); if (found) return found; }
return null;
}
if (entry && typeof entry === "object") {
for (const key of ["import", "node", "default", "require"]) {
if (key in entry) { const found = pickExport(entry[key]); if (found) return found; }
}
}
return null;
};
const packageEntry = (dir) => {
const json = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
const root = json.exports && typeof json.exports === "object" && !Array.isArray(json.exports) && "." in json.exports ? json.exports["."] : json.exports;
return path.join(dir, pickExport(root) || json.module || json.main || "index.js");
};
const optionalDirs = (name) => [...new Set([packageDirFrom(CWD, name), packageDirFrom(SCRIPT_DIR, name)].filter(Boolean))];
const loadOptional = async (name) => {
for (const dir of optionalDirs(name)) {
try { return await import(url.pathToFileURL(packageEntry(dir)).href); } catch { /* 다음 후보 */ }
}
return null;
};
const readOptionalSource = (name) => {
for (const dir of optionalDirs(name)) {
try { return fs.readFileSync(packageEntry(dir), "utf8"); } catch { /* 다음 후보 */ }
}
return null;
};
// CJS 를 import() 하면 내보낸 값이 default 아래에 한 겹 더 들어갈 수 있다
const exportOf = (mod, test) => [mod, mod && mod.default, mod && mod.default && mod.default.default].find((m) => m && test(m)) || null;
// ── 브라우저 확보 (puppeteer-core + 실행 파일 자동 탐색) ──
const LOCAL_APP_DATA = process.env.LOCALAPPDATA ? process.env.LOCALAPPDATA.replace(/\\/g, "/") : null;
const HOME = process.env.HOME || process.env.USERPROFILE || "";
const fromPath = (names) => {
const out = [];
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
if (dir) for (const name of names) out.push(path.join(dir, name));
}
return out;
};
const CHROME_CANDIDATES = [
process.env.CHROME_PATH,
CFG.chromePath,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
LOCAL_APP_DATA && `${LOCAL_APP_DATA}/Google/Chrome/Application/chrome.exe`,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
HOME && `${HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
// Edge 는 같은 Chromium 엔진이다. Windows 에는 기본으로 있어 Chrome 이 없는 기기에서도 게이트가 뜬다.
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/microsoft-edge",
...fromPath(process.platform === "win32" ? ["chrome.exe", "msedge.exe"] : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]),
].filter((p) => typeof p === "string" && p.length > 0);
const CHROME = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
// 설정 파일(gate.config.json)이 실행 파일을 지정할 수 있으므로 무엇을 실행하는지 항상 드러낸다
if (CHROME && CHROME === CFG.chromePath && CHROME !== process.env.CHROME_PATH) {
console.log(`브라우저 실행 파일(gate.config chromePath): ${CHROME}`);
}
const pageUrl = (p) => (p.url ? p.url : url.pathToFileURL(path.resolve(CWD, p.path)).href);
const hasPath = (p) => typeof p.path === "string" && p.path.length > 0;
const viewsOf = (p) => (Array.isArray(p.views) && p.views.length ? p.views.map(String) : ["single"]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── L0 정적 (옵션) ──
// 프로젝트에 설치된 CLI 를 셸 없이 실행한다. 예전 `spawnSync("npx", …, { shell: true })` 는
// gate.config 의 path 값이 셸 문자열로 이어 붙어 명령 주입이 됐고(`"index.html & calc"`),
// Windows 에서는 cwd 의 npx.cmd 가 PATH 보다 먼저 잡혔다. 없는 도구는 받지 않고 미검증으로 남긴다.
const findLocalBin = (pkg) => {
for (let dir = CWD; ; dir = path.dirname(dir)) {
const meta = path.join(dir, "node_modules", pkg, "package.json");
if (fs.existsSync(meta)) {
try {
const json = JSON.parse(fs.readFileSync(meta, "utf8"));
const rel = typeof json.bin === "string" ? json.bin : json.bin && json.bin[pkg];
return rel ? path.join(path.dirname(meta), rel) : null;
} catch { return null; }
}
if (path.dirname(dir) === dir) return null;
}
};
const runLocalBin = (pkg, args) => {
const bin = findLocalBin(pkg);
return bin ? spawnSync(process.execPath, [bin, ...args], { encoding: "utf8" }) : null;
};
// 설정의 path 는 cwd 아래 실제 파일만 인자로 넘긴다(옵션으로 해석되지 않게 ./ 를 붙인다)
const safePagePath = (p) => {
const abs = path.resolve(CWD, p.path);
const rel = path.relative(CWD, abs);
if (!rel || rel.startsWith("..") || path.isAbsolute(rel) || !fs.existsSync(abs)) return null;
return "./" + rel.split(path.sep).join("/");
};
if (CFG.checks.static) {
const css = runLocalBin("stylelint", ["**/*.css"]);
if (!css) unverified("L0 stylelint", "미설치 — 프로젝트에 stylelint 가 없다");
else css.status === 0 ? pass("L0 stylelint") : fail("L0 stylelint", (css.stdout || "").slice(0, 200));
const staticPages = CFG.pages.filter(hasPath);
const files = staticPages.map(safePagePath);
if (files.some((f) => f === null)) {
fail("L0 html-validate", "pages[].path 는 cwd 아래 실제 파일이어야 한다: " + staticPages.filter((_, i) => files[i] === null).map((p) => p.path).join(", "));
} else if (staticPages.length) {
const html = runLocalBin("html-validate", files);
if (!html) unverified("L0 html-validate", "미설치 — 프로젝트에 html-validate 가 없다");
else html.status === 0 ? pass("L0 html-validate") : fail("L0 html-validate", (html.stdout || "").slice(0, 200));
} else unverified("L0 html-validate", "path 없음(url 전용 페이지)");
} else off("L0 정적", "checks.static=false");
// ── L0 정적: 죽은 선택자·토큰 위생 (브라우저 불필요. 실측 사고:
// 컴포넌트 제거 시 CSS 만 남아 12종의 미사용 선택자가 쌓였고, 토큰 밖 상수색 8건이 눈 검사를 통과했다) ──
const pageDir = (p) => path.dirname(path.resolve(CWD, p.path));
const cssFilesOf = (p) => {
const out = [];
if (!hasPath(p)) return out;
let html = "";
try { html = fs.readFileSync(path.resolve(CWD, p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<link[^>]+rel=["']stylesheet["'][^>]*>/g)) {
const href = /href=["']([^"']+)["']/.exec(tag[0])?.[1];
if (href && !/^https?:/.test(href)) {
const f = path.resolve(pageDir(p), href.split("?")[0]);
if (fs.existsSync(f)) out.push(f);
}
}
// @import 로 딸려오는 로컬 파일(토큰 등)까지 따라간다
for (const f of [...out]) {
try {
for (const im of fs.readFileSync(f, "utf8").matchAll(/@import\s+(?:url\()?["']?([^"')]+)["']?\)?/g)) {
if (!/^https?:/.test(im[1])) {
const g = path.resolve(path.dirname(f), im[1]);
if (fs.existsSync(g) && !out.includes(g)) out.push(g);
}
}
} catch { /* 읽기 실패는 아래 검사가 각자 보고 */ }
}
return out;
};
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
const contractFilesOf = (p, key, fallback) => {
const specs = p.staticContract?.[key];
if (!Array.isArray(specs)) return fallback;
return specs.map((file) => path.resolve(CWD, file));
};
const scriptFilesOf = (p) => {
const out = [];
if (!hasPath(p)) return out;
let html = "";
try { html = fs.readFileSync(path.resolve(CWD, p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
const src = tag[1];
if (/^(?:https?:|data:)/.test(src)) continue;
const file = path.resolve(pageDir(p), src.split("?")[0]);
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
}
return out;
};
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
hasPath(p) ? [path.resolve(CWD, p.path), ...scriptFilesOf(p)] : [],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
// CSS 원문에서 주석·문자열·url() 내용을 지운다. 지우지 않고 전체 텍스트에 정규식을 걸면
// `url(fonts/Brand.woff2)` 의 `.woff2` 가 클래스로, `content:"a.zip"` 의 `.zip` 이 클래스로 잡힌다(실측).
const readCss = (file) => fs.readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g, '""')
.replace(/url\(\s*[^)]*\)/gi, "url()");
// 선택자(`{` 앞 프렐류드)만 뽑는다. 중첩 규칙이면 프렐류드 앞에 선언이 붙어 오므로 마지막 `;` 뒤만 쓴다.
const cssPreludes = (css) => [...css.matchAll(/([^{}]*)\{/g)]
.map((m) => m[1].slice(m[1].lastIndexOf(";") + 1).trim())
.filter((prelude) => prelude && !prelude.startsWith("@"));
// 선언값만 뽑는다. `a:hover{` 의 `hover` 는 뒤가 `{` 라 값이 아니고, 마지막 선언은 `;` 없이 `}` 로 끝날 수 있다.
const cssValues = (css) => [...css.matchAll(/:\s*([^;{}]+)(?=[;}])/g)].map((m) => m[1]);
const COLOR_LITERAL = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9A-Za-z_-])|\b(?:rgba?|hsla?|hwb|oklch|oklab|lab|lch|color)\([^)]*\)/g;
// 0·50%·키워드·알약(큰 고정값)은 "토큰으로 옮길 반지름 값"이 아니다
const RADIUS_NEUTRAL = /^(?:0(?:px|rem|em|%)?|50%|100%|inherit|initial|unset|revert|revert-layer|9{3,}(?:px|rem|em)?|100v(?:w|h|max|min)|calc\(infinity\s*\*\s*1px\))$/i;
if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) {
if (!hasPath(p)) { unverified(`${p.name} 죽은 선택자`, "path 없음(url 전용 페이지)"); continue; }
const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
const documents = selectorDocumentsOf(p);
const missing = missingFiles([...files, ...documents]);
if (missing.length) {
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(CWD, file)).join(", ")}`);
continue;
}
if (!files.length) { unverified(`${p.name} 죽은 선택자`, "로컬 CSS 없음(<link rel=stylesheet> 로 연결된 로컬 파일만 읽는다 — 인라인 <style>·빌드 산출 CSS 는 staticContract.selectorFiles 로 지정한다)"); continue; }
// 사용 판정은 토큰 일치다. 부분 문자열로 보면 `.card` 가 본문의 "discard" 한 단어로 사용됨이 된다.
const used = new Set(documents.map((file) => fs.readFileSync(file, "utf8")).join("\n").split(/[^A-Za-z0-9_-]+/));
const dead = new Set();
for (const f of files) {
for (const prelude of cssPreludes(readCss(f))) {
for (const m of prelude.matchAll(/\.(-?[A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!used.has(cls)) dead.add(cls);
}
}
}
dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
}
} else off("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) {
if (!hasPath(p)) { unverified(`${p.name} 토큰 위생`, "path 없음(url 전용 페이지)"); continue; }
const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
if (!files.length) { unverified(`${p.name} 토큰 위생`, "로컬 CSS 없음(<link rel=stylesheet> 로 연결된 로컬 파일만 읽는다 — staticContract.hygieneFiles 로 지정한다)"); continue; }
const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
const missing = missingFiles([...files, ...tokenFiles]);
if (missing.length) {
fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(CWD, file)).join(", ")}`);
continue;
}
const tokenNames = new Set();
for (const f of new Set([...tokenFiles, ...files])) {
for (const m of readCss(f).matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
}
const colors = []; const colorKinds = new Set(); const radii = new Set();
for (const f of files) {
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다. 마지막 선언은 `;` 없이
// `}` 로 끝날 수 있다(미니파이 CSS 의 기본 꼴) — `;` 만 종결로 보면 토큰 파일 자신이 실패한다.
const src = readCss(f).replace(/--[A-Za-z0-9_-]+\s*:\s*[^;{}]*(?=[;}])/g, "");
// 색은 선언값에서만 찾는다. 전체 텍스트에서 찾으면 `#fade{}` 같은 ID 선택자가 색 리터럴로 잡힌다.
for (const value of cssValues(src)) {
for (const m of value.matchAll(COLOR_LITERAL)) {
if (m[0].includes("var(")) continue; // rgb(var(--ink-rgb) / .5) 는 토큰에서 온 값이다
colors.push(`${path.basename(f)}:${m[0]}`);
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
}
}
for (const m of src.matchAll(/border-radius\s*:\s*([^;{}]+)(?=[;}])/g)) {
const value = m[1].replace(/\s*!important\s*$/i, "").trim();
if (!value.includes("var(") && !RADIUS_NEUTRAL.test(value)) radii.add(value);
}
}
const budgets = p.staticContract?.budgets || {};
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
const bad = [];
if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
}
bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
}
} else off("토큰 위생", "checks.cssHygiene=false");
// ── 페이지 안에서 도는 측정 도구 ──
const UTILS = `
window.__lines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const ls = [];
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
return window.__lines(text || el);
};
// 요소 전체를 하나의 Range 로 재면(예: display:inline-grid 로 두 텍스트 블록을 세로로
// 쌓은 의도된 로크업) 자식 인라인 박스 rect 가 섞여 줄 수가 과다 계산된다(실측: 조각달
// ".wordmark" <span>+<small> 2블록이 "3줄"로 오카운트). 비어 있지 않은 텍스트 노드
// 하나하나를 각자의 Range 로 재면 이 섞임이 없다 — 블록마다 실제 줄 수만 남는다.
window.__brandTextBlocks = (el) => {
if (!el) return [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const out = [];
let n;
while ((n = walker.nextNode())) {
const text = n.textContent.trim();
if (text) out.push({ text, lines: window.__lines(n) });
}
return out;
};
window.__heading = (sel) => {
let el;
try { el = document.querySelector(sel); }
catch { return { state: "invalid" }; }
if (!el) return { state: "missing" };
if (!el.textContent.trim()) return { state: "empty" };
const box = el.getBoundingClientRect();
const hiddenAncestor = (() => {
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
const style = getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || style.contentVisibility === "hidden" || parseFloat(style.opacity) <= 0) return true;
}
return false;
})();
if (hiddenAncestor || box.width <= 0 || box.height <= 0) {
return { state: "hidden" };
}
const range = document.createRange();
range.selectNodeContents(el);
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
const lines = [];
for (const top of tops) if (!lines.length || top - lines[lines.length - 1] > 5) lines.push(top);
return { state: "visible", lines: lines.length, rects: rects.map((r) => ({ left: r.left, right: r.right, top: r.top, bottom: r.bottom })) };
};
window.__headingScaleAudit = (sel) => {
const heading = window.__heading(sel);
if (heading.state !== "visible") return heading;
const el = document.querySelector(sel);
const clipping = [];
const viewportWidth = document.documentElement.clientWidth;
for (const rect of heading.rects) {
if (rect.left < -1 || rect.right > viewportWidth + 1) clipping.push("viewport");
}
for (let ancestor = el; ancestor && ancestor !== document.documentElement; ancestor = ancestor.parentElement) {
const cs = getComputedStyle(ancestor);
const box = ancestor.getBoundingClientRect();
const name = ancestor.className ? "." + String(ancestor.className).trim().split(/\\s+/)[0] : ancestor.tagName.toLowerCase();
if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowX) && heading.rects.some((rect) => rect.left < box.left - 1 || rect.right > box.right + 1)) clipping.push(name + "(x)");
if (/^(hidden|clip|auto|scroll)$/.test(cs.overflowY) && heading.rects.some((rect) => rect.top < box.top - 1 || rect.bottom > box.bottom + 1)) clipping.push(name + "(y)");
}
return {
...heading,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
clipping: [...new Set(clipping)],
};
};
`;
// 색 정규화와 시각적 숨김 판정. 페이지 안에서 도는 자기완결 함수다(Node 쪽 값을 참조하지 않는다).
const installColorUtils = () => {
// getComputedStyle 은 oklch()·lab()·color(srgb …)·color-mix() 결과를 rgb() 로 바꿔 주지 않는다.
// 예전 파서는 rgb()/rgba() 만 읽고 나머지 요소를 판정에서 조용히 뺐다(실측: Tailwind v4 기본
// 팔레트는 oklch 라 대부분의 텍스트를 재지 않고 "대비 AA PASS"를 냈다). 문법을 하나씩 따라가지 않고
// 브라우저에 맡긴다 — 1×1 캔버스에 칠한 뒤 픽셀을 읽으면 브라우저가 아는 모든 색이 sRGB 8비트로 나온다.
const cache = new Map();
let ctx;
const LEGACY = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/;
window.__rgba = (value) => {
const key = String(value || "");
if (cache.has(key)) return cache.get(key);
let out = null;
const legacy = LEGACY.exec(key);
if (legacy) {
out = { r: +legacy[1], g: +legacy[2], b: +legacy[3], a: legacy[4] === undefined ? 1 : +legacy[4] };
} else if (key) {
if (ctx === undefined) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
ctx = canvas.getContext("2d", { willReadFrequently: true }) || null;
}
if (ctx) {
// 해석하지 못한 값을 넣으면 fillStyle 이 이전 값으로 남는다. 서로 다른 두 표식으로 확인한다.
ctx.fillStyle = "#010203"; ctx.fillStyle = key; const first = ctx.fillStyle;
ctx.fillStyle = "#040506"; ctx.fillStyle = key; const second = ctx.fillStyle;
if (!(first === "#010203" && second === "#040506")) {
ctx.clearRect(0, 0, 1, 1);
ctx.fillRect(0, 0, 1, 1);
const d = ctx.getImageData(0, 0, 1, 1).data;
out = { r: d[0], g: d[1], b: d[2], a: d[3] / 255 };
}
}
}
cache.set(key, out);
return out;
};
// 스크린리더 전용 텍스트·스킵 링크의 표준 구현(1px 상자 + clip). 눈에 보이지도, 누를 수도 없다.
window.__visuallyHidden = (el, style) => {
const cs = style || getComputedStyle(el);
const r = el.getBoundingClientRect();
if (r.width > 2 || r.height > 2) return false;
return (cs.clip && cs.clip !== "auto") || (cs.clipPath && cs.clipPath !== "none") || (cs.position === "absolute" && cs.overflow !== "visible");
};
};
// ── 브라우저 계층 ──
const browserLayer = async () => {
if (STATIC_ONLY) return unverified("브라우저 계층", "--static-only — 렌더 검사(오버플로·제목·대비·터치 타깃·axe 등)를 돌리지 않았다");
if (!CHROME) return unverified("브라우저 계층", "브라우저 실행 파일을 찾지 못했다 — Chrome·Chromium·Edge 를 설치하거나 CHROME_PATH 를 지정한다");
const puppeteer = exportOf(await loadOptional("puppeteer-core"), (m) => typeof m.launch === "function");
if (!puppeteer) return unverified("브라우저 계층", "puppeteer-core 를 찾지 못했다 — 실행한 디렉터리(cwd)나 이 스크립트 위치의 node_modules 에 있어야 한다");
// axe-core 선택 해석 (checks.axe: "auto" 기본). 해석되면 각 page/view 에 소스를 주입해 실행하고,
// 안 되면 "미검증"으로 보고한다 — 통과로 추정하지 않는다.
const axeSource = CFG.checks.axe !== false ? readOptionalSource("axe-core") : null;
let browser;
try {
browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1", ...(Array.isArray(CFG.chromeArgs) ? CFG.chromeArgs.map(String) : [])] });
} catch (e) {
return unverified("브라우저 계층", `브라우저 실행 실패(${CHROME}) — ${errText(e)}`);
}
try {
for (const p of CFG.pages) {
// 페이지 하나가 타임아웃·예외로 죽어도 누적 결과와 나머지 페이지를 버리지 않는다
const page = await browser.newPage();
const mark = results.length;
try {
await auditPage(page, p, axeSource);
} catch (e) {
const loaded = results.length > mark;
fail(loaded ? `${p.name} 실행 오류` : `${p.name} 로드`, `${errText(e)} — 이 페이지의 남은 검사를 돌리지 못했다`);
} finally {
await page.close().catch(() => {});
}
}
if (CFG.checks.visual) await visualLayer(browser);
} catch (e) {
fail("브라우저 계층 실행 오류", errText(e));
} finally {
await browser.close().catch(() => {});
}
};
const auditPage = async (page, p, axeSource) => {
const href = pageUrl(p);
const views = viewsOf(p);
const load = async () => {
await page.goto(href, { waitUntil: CFG.load.waitUntil, timeout: CFG.load.timeoutMs });
await page.evaluate(UTILS);
await page.evaluate(installColorUtils);
};
// 런타임 오류 수집 — 깨진 화면을 재고 통과시키지 않는다(스크립트 예외로 반쯤 그려진 화면, 404 난 로컬 CSS)
const runtime = { errors: new Set(), requests: new Set() };
const isLocal = (u) => {
if (u.startsWith("file:")) return true;
try { return /^https?:/.test(u) && new URL(u).origin === new URL(href).origin; } catch { return false; }
};
page.on("pageerror", (e) => runtime.errors.add(errText(e)));
// requestfailed 는 "파일이 없다"만 센다. 게이트는 뷰포트를 바꿔 가며 같은 페이지를 여러 번 다시 여는데,
// 그때 취소된 요청이 ERR_FAILED·ERR_ABORTED 로 들어온다(실측: 정상 CSS·폰트가 실패로 집계됐다).
// file:// 에서 못 읽는 manifest 도 ERR_FAILED 다. HTTP 의 4xx·5xx 는 아래 response 에서 센다.
page.on("requestfailed", (req) => {
const reason = req.failure()?.errorText || "";
if (isLocal(req.url()) && /ERR_FILE_NOT_FOUND/.test(reason)) runtime.requests.add(`${req.url().split("/").pop().split("?")[0].slice(0, 40)}(없는 파일)`);
});
page.on("response", (res) => {
if (res.status() >= 400 && isLocal(res.url())) runtime.requests.add(`${res.url().split("/").pop().slice(0, 40)}(${res.status()})`);
});
await page.setCacheEnabled(false);
await page.setViewport({ width: 1440, height: 900 });
await load();
await page.evaluate(() => document.fonts.ready);
// ── SEO / meta (조사 기반 체크리스트) ──
if (CFG.checks.meta) {
const meta = await page.evaluate(() => {
const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; };
const title = document.title.trim();
const desc = get('meta[name="description"]');
const og = (k) => get(`meta[property="og:${k}"]`);
const imgs = [...document.querySelectorAll("img")];
const hex = (value) => { const c = window.__rgba(value); return c ? "#" + [c.r, c.g, c.b].map((n) => Math.round(n).toString(16).padStart(2, "0")).join("") : null; };
const themeColor = get('meta[name="theme-color"]');
// 프리셋 전환에서 브랜드 표면이 누락되면 theme-color 만 옛 값으로 남는다 — 실제 배경과 비교해 잡는다
const bodyBg = (() => {
const body = window.__rgba(getComputedStyle(document.body).backgroundColor);
return body && body.a > 0 ? getComputedStyle(document.body).backgroundColor : getComputedStyle(document.documentElement).backgroundColor;
})();
return {
title, titleLen: title.length,
desc, descLen: desc ? desc.length : 0,
viewport: get('meta[name="viewport"]'),
lang: document.documentElement.getAttribute("lang"),
charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"),
canonical: get('link[rel="canonical"]', "href"),
ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"),
twitter: get('meta[name="twitter:card"]'),
robots: get('meta[name="robots"]'),
favicon: !!document.querySelector('link[rel~="icon"]'),
// 표기(#FFF·#ffffff·rgb()·oklch())가 달라도 같은 색이면 같다 — 둘 다 브라우저가 해석한 값으로 비교한다
themeColor, themeHex: themeColor ? hex(themeColor) : null,
bodyBg, bodyHex: (() => { const c = window.__rgba(bodyBg); return c && c.a > 0 ? hex(bodyBg) : null; })(),
h1Count: document.querySelectorAll("h1").length,
// alt="" 는 장식 이미지 선언(의도)이다 — 실패는 alt 속성 자체가 없는 img 만
imgNoAlt: imgs.filter((i) => !i.hasAttribute("alt")).length,
imgDecorative: imgs.filter((i) => i.hasAttribute("alt") && i.getAttribute("alt") === "").length,
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].every((s) => { try { JSON.parse(s.textContent); return true; } catch { return false; } }),
jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length,
};
});
const m = [];
if (!meta.title) m.push("title 없음");
else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`);
if (!meta.desc) m.push("description 없음");
else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`);
if (!meta.viewport) m.push("viewport 없음");
if (!meta.lang) m.push("html lang 없음");
if (!meta.charset) m.push("charset 없음");
if (meta.ogTitle === null) m.push("og:title 없음");
if (meta.ogImage === null) m.push("og:image 없음");
if (meta.twitter === null) m.push("twitter:card 없음");
if (!meta.favicon) m.push("favicon 없음");
if (meta.themeColor && meta.themeHex && meta.bodyHex && meta.themeHex !== meta.bodyHex) {
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
}
if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — og:description·og:url·canonical 은 읽기만 하고 단언하지 않는다
m.length ? fail(`${p.name} SEO/meta`, m.join(" · ")) : pass(`${p.name} SEO/meta`, `title ${meta.titleLen}자·desc ${meta.descLen}자·OG✓·alt 누락 ${meta.imgNoAlt} · 장식(alt="") ${meta.imgDecorative}`);
}
// ── 뷰 전환 — 설정에 적힌 뷰는 실제로 전환됐을 때만 잰다.
// 예전에는 버튼을 못 찾아도 넘어가서 없는 뷰 이름이 같은 화면의 측정값으로 PASS 를 받았다(실측:
// views 에 오타 둘을 넣어도 세 뷰 모두 통과). 못 찾으면 그 뷰는 실패 한 줄로 끝낸다(G8). ──
const badViews = new Set();
const enterView = async (v) => {
if (badViews.has(v)) return false;
if (v === "single") return true;
let why = null;
if (!p.viewAttribute) {
why = "viewAttribute 없음 — views 에 이름을 적으려면 전환 버튼의 속성 이름이 필요하다(전환이 없는 페이지면 views 를 지운다)";
} else {
const selector = `[${p.viewAttribute}="${v.replace(/["\\]/g, "\\$&")}"]`;
const found = await page.evaluate((sel) => {
let btn = null;
try { btn = document.querySelector(sel); } catch { return false; }
if (!btn) return false;
btn.click();
return true;
}, selector);
if (!found) why = `전환 버튼 없음 (${selector})`;
}
if (why) {
badViews.add(v);
fail(`${p.name}/${v} 뷰 전환`, why);
return false;
}
await sleep(120);
return true;
};
for (const v of views) {
if (!(await enterView(v))) continue;
// 오버플로 — 전 폭
const overs = [];
for (const w of CFG.widths) {
await page.setViewport({ width: w, height: 900 });
await sleep(60);
const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) overs.push(`+${over}px@${w}`);
}
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// 제목은 항상 존재·가시성을 확인하고, 줄 수는 페이지가 명시한 계약일 때만 검사한다.
await page.setViewport({ width: 1440, height: 900 });
const h1Selector = p.h1 || "h1";
const h1 = await page.evaluate((sel) => window.__heading(sel), h1Selector);
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : null;
if (h1.state === "missing") fail(`${p.name}/${v} h1`, `선택자 없음 (${h1Selector})`);
else if (h1.state === "empty") fail(`${p.name}/${v} h1`, "텍스트 없음");
else if (h1.state === "hidden") fail(`${p.name}/${v} h1`, `선택자가 숨김 (${h1Selector})`);
else if (h1.state === "invalid") fail(`${p.name}/${v} h1`, `잘못된 선택자 (${h1Selector})`);
else if (h1.lines < 1) fail(`${p.name}/${v} h1`, "텍스트 없음");
else if (h1MaxLines !== null && h1.lines > h1MaxLines) fail(`${p.name}/${v} h1`, `${h1.lines}줄(최대 ${h1MaxLines}줄)`);
else if (h1MaxLines !== null) pass(`${p.name}/${v} h1 최대 ${h1MaxLines}줄`, `${h1.lines}줄`);
else pass(`${p.name}/${v} h1 보임`, `${h1.lines}줄(줄수 계약 없음)`);
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) {
const stacked = await page.evaluate((limit) => {
const out = [];
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent || el.querySelector("*")) return;
const txt = el.textContent.trim();
if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10));
});
return [...new Set(out)].slice(0, 5);
}, CFG.thresholds.stackLines);
stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`);
}
// 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다
// (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙)
if (CFG.checks.rhythm) {
const rhythm = await page.evaluate(() => {
const groups = {};
document.querySelectorAll("body *").forEach((el) => {
if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return;
const cls = el.classList[0];
const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls;
const r = el.getBoundingClientRect();
(groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement });
});
const issues = [];
for (const [key, items] of Object.entries(groups)) {
if (items.length < 4) continue;
const spread = (v) => Math.max(...v) - Math.min(...v);
const cls = key.split("|")[1];
const hUniform = spread(items.map((i) => i.h)) <= 4;
const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도
const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도
// 1) 트랙 폭: 숫자 라벨·빈 셀은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다.
// 한국어 라벨은 글자수가 같아도 내용 폭차가 자연스러우므로 대상에서 뺀다(오탐 방지).
const wSpread = spread(items.map((i) => i.w));
if (hUniform && wSpread > 6 && (isDigits || isEmpty)) {
issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`);
}
// 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다
if (spread(items.map((i) => i.x)) < 3 && hUniform) {
const ys = items.map((i) => i.y).sort((a, b) => a - b);
const deltas = ys.slice(1).map((y, i) => y - ys[i]);
if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`);
} else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) {
const xs2 = items.map((i) => i.x).sort((a, b) => a - b);
const d2 = xs2.slice(1).map((x, i) => x - xs2[i]);
if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`);
}
}
return issues.slice(0, 4);
});
rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙");
}
// 대비 — 글자와 그 뒤 화소를 "보이는 대로" 합성해서 잰다.
// · 색은 __rgba 로 정규화한다(oklch·lab·color()·color-mix 포함). 못 읽으면 건너뛰지 않고 미검증으로 센다.
// · 배경은 documentElement 까지 올라간다. html 에 건 배경을 빼면 어두운 사이트의 흰 글자가 1:1 로
// 거짓 실패하고, 어두운 글자는 거짓 통과한다(실측).
// · 조상 opacity 는 그 조상의 배경과 글자를 한 묶음으로 흐리게 한다. 묶음 단위로 알파를 곱한다.
// · 뒤에 이미지·그라디언트가 깔리면 계산값으로 단정하지 않는다 — "대비 미검증 n요소"로 센다.
// · 비활성 컨트롤(disabled·aria-disabled)의 글자는 WCAG 1.4.3 의 예외라 판정에서 빼고 수만 남긴다.
// 흐리게 한 opacity 를 합성하기 시작하면서 이 예외가 없으면 비활성 버튼이 전부 실패한다(실측).
if (CFG.checks.contrast) {
const res = await page.evaluate((cn, cl) => {
const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
const over = (top, under) => {
const a = top.a + under.a * (1 - top.a);
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
const ch = (k) => (top[k] * top.a + under[k] * under.a * (1 - top.a)) / a;
return { r: ch("r"), g: ch("g"), b: ch("b"), a };
};
const WHITE = { r: 255, g: 255, b: 255, a: 1 }; // 아무 배경도 없을 때의 캔버스 기본색
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "OPTION", "OPTGROUP"]);
const styles = new Map();
const styleOf = (node) => { let s = styles.get(node); if (!s) { s = getComputedStyle(node); styles.set(node, s); } return s; };
const seen = new Set(); const out = [];
let checked = 0, unvImage = 0, unvColor = 0, inactive = 0;
const isInactive = (el) => {
if (el.closest(":disabled, [aria-disabled='true']")) return true;
const label = el.closest("label");
return !!(label && label.control && label.control.disabled);
};
document.querySelectorAll("body *").forEach((el) => {
if (SKIP_TAGS.has(el.tagName)) return;
// fixed 요소는 offsetParent 가 null 이다(G13) — 보임은 렌더 상자 유무로 판정한다
if (!el.getClientRects().length) return;
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return;
const cs = styleOf(el);
if (cs.visibility !== "visible" || window.__visuallyHidden(el, cs)) return;
if ((cs.webkitBackgroundClip || cs.backgroundClip) === "text") { unvImage++; return; }
const fill = cs.webkitTextFillColor;
const text = window.__rgba(fill && fill !== cs.color ? fill : cs.color);
if (!text) { unvColor++; return; }
let fg = { ...text }, bg = { r: 0, g: 0, b: 0, a: 0 };
let opacity = 1, image = false, unknown = false;
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
const s = styleOf(node);
if (bg.a < 0.999) {
if (s.backgroundImage && s.backgroundImage !== "none") { image = true; break; }
const layer = window.__rgba(s.backgroundColor);
if (!layer) { unknown = true; break; }
if (layer.a > 0) { fg = over(fg, layer); bg = over(bg, layer); }
}
const o = parseFloat(s.opacity);
if (o < 1) { fg.a *= o; bg.a *= o; opacity *= o; }
}
if (text.a * opacity < 0.02) return; // 사실상 안 보이는 글자(닫힌 상태의 opacity:0 등)
if (isInactive(el)) { inactive++; return; }
if (image) { unvImage++; return; }
if (unknown) { unvColor++; return; }
const fgFinal = over(fg, WHITE), bgFinal = over(bg, WHITE);
const l1 = lum(fgFinal), l2 = lum(bgFinal);
const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2);
const size = parseFloat(cs.fontSize) || 0; const w = +cs.fontWeight || 400;
const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn;
checked++;
if (ratio < need) {
const k = cs.color + Math.round(size) + Math.round(ratio * 10);
if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); }
}
});
return { bad: out.slice(0, 4), total: out.length, checked, unvImage, unvColor, inactive };
}, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge);
const unvCount = res.unvImage + res.unvColor;
const unvNote = unvCount ? `대비 미검증 ${unvCount}요소(이미지·그라디언트 배경 ${res.unvImage}·색 해석 불가 ${res.unvColor})` : "";
if (res.total) fail(`${p.name}/${v} 대비`, res.bad.join(",") + (res.total > res.bad.length ? ` 외 ${res.total - res.bad.length}종` : "") + (unvNote ? ` · ${unvNote}` : ""));
else if (unvCount) unverified(`${p.name}/${v} 대비`, `${unvNote} — 실제 렌더를 열어 판정한다. 나머지 ${res.checked}요소는 AA 충족`);
else pass(`${p.name}/${v} 대비 AA`, `검사 요소 ${res.checked}개${res.inactive ? ` · 비활성 컨트롤 ${res.inactive}요소 제외` : ""}`);
}
}
// ── 스케일×폭 제목 행렬 (글꼴 확대에서 줄 수 증가와 잘림을 분리한다) ──
if (CFG.checks.scaleMatrix) {
const bad = [];
const measurements = [];
for (const w of [375, 390]) {
for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
await load();
const baseRootSize = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).fontSize));
if (!Number.isFinite(baseRootSize) || baseRootSize <= 0) {
bad.push(`${w}@${s}x:root font-size 측정 실패`);
continue;
}
await page.addStyleTag({ content: `html { font-size: ${baseRootSize * s}px !important; }` });
const h1 = await page.evaluate((sel) => window.__headingScaleAudit(sel), p.h1 || "h1");
const point = `${w}@${s}x`;
if (h1.state === "missing") { bad.push(`${point}:h1 선택자 없음 (${p.h1 || "h1"})`); continue; }
if (h1.state === "empty") { bad.push(`${point}:h1 텍스트 없음`); continue; }
if (h1.state === "hidden") { bad.push(`${point}:h1 선택자가 숨김 (${p.h1 || "h1"})`); continue; }
if (h1.state === "invalid") { bad.push(`${point}:h1 잘못된 선택자 (${p.h1 || "h1"})`); continue; }
if (h1.lines < 1) { bad.push(`${point}:h1 텍스트 없음`); continue; }
measurements.push(`${point}:${h1.lines}줄`);
if (s > 1 && h1.documentOverflow > 1) bad.push(`${point}:문서 가로오버플로 +${h1.documentOverflow}px`);
if (s > 1 && h1.clipping.length) bad.push(`${point}:제목 텍스트 클리핑 ${h1.clipping.join(",")}`);
}
}
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `줄수 정보 ${measurements.join(", ")}`);
} else off(`${p.name} 스케일×폭 h1`, "checks.scaleMatrix=false");
// ── 브랜드 줄바꿈 — 텍스트 블록별(예: 로고의 로크업 두 줄) 각자 1줄이어야 한다.
// 여러 텍스트 블록을 한 Range 로 합쳐 재면 자식 인라인 박스가 섞여 과다 계산된다.
// 예전에는 이 검사가 scaleMatrix 블록 안에 있었고 checks.wrap 은 읽히지 않아, 꺼도 꺼지지 않았다. ──
if (p.brand) {
if (CFG.checks.wrap) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await load();
// 동일 셀렉터가 여러 개(사이드바+탑바 등)면 보이는 것을 잰다 — display:none 의 rect 는 0
const blocks = await page.evaluate((sel) => {
let els;
try { els = [...document.querySelectorAll(sel)]; } catch { return null; }
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
return el ? window.__brandTextBlocks(el) : null;
}, p.brand);
if (blocks === null) {
fail(`${p.name} 브랜드 줄바꿈`, `선택자 매칭 없음 (${p.brand})`);
} else {
const wrapped = blocks.find((b) => b.lines >= 2);
wrapped
? fail(`${p.name} 브랜드 줄바꿈`, `"${wrapped.text.slice(0, 12)}" ${wrapped.lines}줄(수축 의심)`)
: pass(`${p.name} 브랜드 줄바꿈 없음`, `텍스트 블록 ${blocks.length}개, 각 1줄`);
}
} else off(`${p.name} 브랜드 줄바꿈`, "checks.wrap=false");
}
// ── 터치 타깃 — 두 층으로 나눠 판정한다 (실측: coarse 포인터에서 28px 버튼이 정확히 못 눌렸다는
// 보고로 시작된 검사).
// 1) WCAG 2.5.8(하드 게이트, 규범): 인라인이 아닌 대상 중 min(width,height) < 24px 는
// 간격 예외(24px 지름 원이 다른 대상의 bbox·다른 undersized 대상의 원과 겹치지 않음)를
// 계산한다. 문장 속 인라인 링크는 WCAG 2.5.8 의 inline 예외라 이 층에서 아예 빼고,
// 계약 층에서만(24px) 본다.
// 2) 계약 층(프로젝트·플랫폼 목표): HIG 44pt·M3 48dp 기준 컨트롤 ≥44px, 인라인 ≥24px.
// tapTargetPolicy:"contract"(기본)면 계약 미달도 실패,
// "wcag"면 WCAG 층 실패만 실패로 보고하고 계약 미달은 통과 detail 에 참고로만 남긴다.
// WCAG 층의 인라인 예외: computed display 가 inline 이고, 같은 줄 흐름에 자신 밖의 글자가 있을
// 때만이다(또는 tapTargetsInline 선택자). 예전에는 <a href> 전부를 인라인으로 봐서, 붙어 있는
// 16px 아이콘 링크가 WCAG 층에서 빠지고 "계약 참고"로 내려갔다(실측).
// 계약 층의 문턱 분류는 따로다: 텍스트 흐름 요소(display:inline)와 링크(<a href>)는
// inlineTargetMin, 나머지 컨트롤은 tapTargetMin 을 쓴다(종전과 같다).
// 시각적으로 숨긴 스킵 링크·입력은 대상이 아니다 — 예전에는 A14 가 권하는 스킵 링크를 넣으면
// "1×1"로 이 검사가 실패했다(실측). ──
if (CFG.checks.tapTargets) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await load();
for (const v of views) {
if (!(await enterView(v))) continue;
const result = await page.evaluate((min, inlineMin, inlineSel, policy) => {
const inSentence = (el) => {
// 부모에 직접 딸린 글자가 있으면 문장 속이다. 부모도 inline(strong·em 등)이면 한 겹 더 올라간다.
for (let node = el; node.parentElement; node = node.parentElement) {
const parent = node.parentElement;
if ([...parent.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim())) return true;
if (getComputedStyle(parent).display !== "inline") return false;
}
return false;
};
const labelOf = (el) => {
if (el.tagName !== "INPUT" || !/^(checkbox|radio)$/i.test(el.type)) return null;
return el.closest("label") || (el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null);
};
const targets = [];
document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab'], [role='link'], [role='switch'], [role='checkbox']").forEach((el) => {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden") return;
if (el.tagName === "INPUT" && el.type === "hidden") return;
// 숨긴 체크박스·라디오를 label 이 감싸면 누르는 곳은 label 이다
const label = labelOf(el);
const box = label || el;
const r = box.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return;
if (window.__visuallyHidden(box)) return;
const inline = (cs.display === "inline" && !label && inSentence(el)) || (inlineSel || []).some((s) => { try { return el.matches(s); } catch { return false; } });
const linkLike = inline || (!label && (cs.display === "inline" || (el.tagName === "A" && el.hasAttribute("href"))));
const name = box.textContent.trim().slice(0, 8) || el.getAttribute("aria-label") || el.tagName.toLowerCase();
targets.push({ x: r.left, y: r.top, w: r.width, h: r.height, inline, linkLike, label: name });
});
// 간격 예외: 반지름 12 원(중심=bbox 중심)이 (a) 다른 모든 대상의 bbox 와 교차하지 않고
// (b) 다른 undersized 대상의 원과 교차하지 않으면(중심 간 거리 ≥24) 충족.
const exceptionMet = (t) => {
const cx = t.x + t.w / 2, cy = t.y + t.h / 2;
for (const o of targets) {
if (o === t) continue;
const nx = Math.max(o.x, Math.min(cx, o.x + o.w));
const ny = Math.max(o.y, Math.min(cy, o.y + o.h));
if (Math.hypot(cx - nx, cy - ny) < 12) return false;
}
for (const o of targets) {
if (o === t || Math.min(o.w, o.h) >= 24) continue;
const ocx = o.x + o.w / 2, ocy = o.y + o.h / 2;
if (Math.hypot(cx - ocx, cy - ocy) < 24) return false;
}
return true;
};
const fails = []; const notes = [];
for (const t of targets) {
const size = Math.min(t.w, t.h);
const wcagFail = !t.inline && size < 24 && !exceptionMet(t);
const contractMin = t.linkLike ? inlineMin : min;
const contractFail = size < contractMin;
const sizeLabel = `"${t.label}" ${Math.round(t.w)}×${Math.round(t.h)}${t.inline ? "(인라인)" : t.linkLike ? "(링크)" : ""}`;
if (wcagFail) {
fails.push(`WCAG 2.5.8: ${sizeLabel}`);
} else if (contractFail) {
if (policy === "contract") {
fails.push(`계약 ${contractMin}px: ${sizeLabel}`);
} else {
const note = !t.inline && size < 24 ? "(WCAG 간격 예외 충족)" : "";
notes.push(`계약 ${contractMin}px: ${sizeLabel}${note}`);
}
}
}
const uniqueFails = [...new Set(fails)], uniqueNotes = [...new Set(notes)];
return { fails: uniqueFails.slice(0, 5), failTotal: uniqueFails.length, notes: uniqueNotes.slice(0, 5), count: targets.length };
}, CFG.thresholds.tapTargetMin, CFG.thresholds.inlineTargetMin, CFG.tapTargetsInline || [], CFG.tapTargetPolicy);
const summary = `컨트롤 ≥${CFG.thresholds.tapTargetMin}px·링크/인라인 ≥${CFG.thresholds.inlineTargetMin}px · 대상 ${result.count}개`;
if (result.fails.length) fail(`${p.name}/${v} 터치타깃`, result.fails.join(" · ") + (result.failTotal > result.fails.length ? ` 외 ${result.failTotal - result.fails.length}건` : ""));
else if (result.notes.length) pass(`${p.name}/${v} 터치타깃`, `${summary} — 계약 참고: ${result.notes.join(" · ")}`);
else pass(`${p.name}/${v} 터치타깃`, summary);
}
} else off(`${p.name} 터치타깃`, "checks.tapTargets=false");
// ── axe-core 접근성 (선택 — checks.axe:"auto" 기본. 해석되면 각 view 에서 WCAG 2.x AA
// 태그로 실행하고, 위반은 규칙 id·영향·대상 수로 보고한다. 해석 안 되면 미검증으로 보고하고
// 통과로 추정하지 않는다) ──
if (CFG.checks.axe === false) {
off(`${p.name} axe`, "checks.axe=false");
} else if (!axeSource) {
unverified(`${p.name} axe`, "axe-core 를 찾지 못했다 — 실행한 디렉터리(cwd)나 이 스크립트 위치의 node_modules 에 있어야 한다");
} else {
await page.setViewport({ width: 1440, height: 900 });
await load();
for (const v of views) {
if (!(await enterView(v))) continue;
await page.addScriptTag({ content: axeSource });
const report = await page.evaluate(async () => {
const r = await window.axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } });
return r.violations.map((v) => ({ id: v.id, impact: v.impact, count: v.nodes.length }));
});
report.length
? fail(`${p.name}/${v} axe`, report.map((r) => `${r.id}(${r.impact}) ${r.count}건`).join(" · "))
: pass(`${p.name}/${v} axe`, "WCAG 2.x AA 위반 0건");
}
}
// ── 왼쪽 인셋(모바일) — 오버플로 검사는 오른쪽만 본다.
// 실측 사고: 제목 16px vs 앱바·카드 24px 혼재가 모든 게이트를 통과했고
// 사용자에게 '가장자리에 붙어 깨졌다'고 보고됐다. 텍스트 노드 Range 로 글자 시작점을 잰다
// (전폭 컨테이너의 보더박스가 아니라. display:none 의 rect=0 은 보임 검사로 제외). ──
if (CFG.checks.leftInset) {
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await load();
for (const v of views) {
if (!(await enterView(v))) continue;
const inset = await page.evaluate(() => {
let min = null;
const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let n;
while ((n = walk.nextNode())) {
if (!n.textContent.trim() || !n.parentElement) continue;
const cs = getComputedStyle(n.parentElement);
if (cs.display === "none" || cs.visibility === "hidden" || cs.position === "fixed") continue;
const range = document.createRange();
range.selectNodeContents(n);
const r = range.getBoundingClientRect();
if (r.width < 2 || r.height < 4 || r.left < 0) continue;
const l = Math.round(r.left);
if (min === null || l < min) min = l;
}
return min;
});
inset === null
? unverified(`${p.name}/${v} 왼쪽 인셋`, "잴 텍스트가 없다")
: inset >= CFG.thresholds.leftInsetMin
? pass(`${p.name}/${v} 왼쪽 인셋`, `최소 텍스트 시작 ${inset}px`)
: fail(`${p.name}/${v} 왼쪽 인셋`, `텍스트가 ${inset}px에서 시작 — 기준 ≥${CFG.thresholds.leftInsetMin}px(가장자리에 붙어 보인다)`);
}
} else off(`${p.name} 왼쪽 인셋`, "checks.leftInset=false");
// ── 런타임 — 위 검사들이 여러 번 다시 여는 동안 모인 스크립트 예외·로컬 요청 실패·깨진 이미지.
// 깨진 화면의 측정값은 통과여도 의미가 없다. 외부 출처(CDN 폰트 등)의 실패는 세지 않는다. ──
if (CFG.checks.runtime) {
const brokenImages = await page.evaluate(() => [...document.querySelectorAll("img")]
.filter((img) => img.getAttribute("src") && img.complete && img.naturalWidth === 0)
.map((img) => String(img.getAttribute("src")).split("/").pop().slice(0, 40)));
const bad = [];
if (runtime.errors.size) bad.push(`스크립트 예외 ${runtime.errors.size}종: ${[...runtime.errors].slice(0, 2).join(" | ")}`);
if (runtime.requests.size) bad.push(`로컬 요청 실패 ${runtime.requests.size}건: ${[...runtime.requests].slice(0, 3).join(", ")}`);
if (brokenImages.length) bad.push(`깨진 이미지 ${brokenImages.length}개: ${[...new Set(brokenImages)].slice(0, 3).join(", ")}`);
bad.length ? fail(`${p.name} 런타임`, bad.join(" · ")) : pass(`${p.name} 런타임`, "스크립트 예외 0·로컬 요청 실패 0·깨진 이미지 0");
} else off(`${p.name} 런타임`, "checks.runtime=false");
};
// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ──
const visualLayer = async (browser) => {
const pixelmatch = exportOf(await loadOptional("pixelmatch"), (m) => typeof m === "function");
const PNG = exportOf(await loadOptional("pngjs"), (m) => typeof m.PNG === "function")?.PNG;
if (!pixelmatch || !PNG) return unverified("L3 시각 회귀", "pixelmatch·pngjs 를 찾지 못했다");
const BASE = path.join(OUT_DIR, "__gate__/baseline");
fs.mkdirSync(BASE, { recursive: true });
const page = await browser.newPage();
let diffs = 0, compared = 0, created = 0;
try {
for (const p of CFG.pages) {
for (const w of [390, 1440]) {
await page.setViewport({ width: w, height: w === 390 ? 844 : 900 });
await page.goto(pageUrl(p), { waitUntil: CFG.load.waitUntil, timeout: CFG.load.timeoutMs });
await sleep(250);
// name 은 설정 값이다 — 경로 구분자·.. 로 기준 디렉터리 밖에 쓰지 않게 파일명 문자만 남긴다
const name = `${String(p.name).replace(/[^A-Za-z0-9_-]/g, "_")}-${w}.png`;
const buf = await page.screenshot();
const cur = PNG.sync.read(Buffer.from(buf));
const bf = path.join(BASE, name);
if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); created++; continue; }
const base = PNG.sync.read(fs.readFileSync(bf));
if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; }
const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 });
const pct = (cnt / (base.width * base.height)) * 100;
compared++;
if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); }
}
}
} finally {
await page.close().catch(() => {});
}
if (diffs) return;
// 기준이 없어 방금 만든 화면은 비교한 것이 아니다. 예전에는 첫 실행이 "0화면 일치"로 통과했다.
if (UPDATE) pass("L3 시각 회귀", `${created}화면 기준갱신`);
else if (created) unverified("L3 시각 회귀", `기준 없음(${created}장 생성 — ${path.relative(CWD, BASE) || BASE}) — 눈으로 확인한 뒤 기준으로 삼는다. 비교한 화면 ${compared}장`);
else pass("L3 시각 회귀", `${compared}화면 일치`);
};
// ── L4 WebKit (옵션: playwright 설치 시) ──
const webkitLayer = async () => {
const webkit = exportOf(await loadOptional("playwright"), (m) => m.webkit && typeof m.webkit.launch === "function")?.webkit;
if (!webkit) return unverified("L4 WebKit", "playwright 를 찾지 못했다");
let b2;
try { b2 = await webkit.launch(); }
catch (e) { return unverified("L4 WebKit", `WebKit 실행 실패 — ${errText(e)}`); }
try {
const pg = await b2.newPage();
await pg.setViewportSize({ width: 390, height: 844 });
let bad = 0;
for (const p of CFG.pages) {
try {
await pg.goto(pageUrl(p), { waitUntil: "networkidle", timeout: CFG.load.timeoutMs });
const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; }
} catch (e) { fail(`L4 WebKit ${p.name} 로드`, errText(e)); bad++; }
}
!bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`);
} finally {
await b2.close().catch(() => {});
}
};
await browserLayer();
if (!CFG.checks.visual) off("L3 시각 회귀", "checks.visual=false");
if (!CFG.checks.webkit) off("L4 WebKit", "checks.webkit=false");
else if (STATIC_ONLY) unverified("L4 WebKit", "--static-only");
else await webkitLayer();
// ── 리포트 ──
// 줄 머리는 넷이다. 미검증을 PASS 로 찍지 않는다 — 읽는 쪽이 PASS 줄 속의 "SKIP"을 찾아 세야 했다.
const LABEL = { pass: "PASS ", fail: "FAIL ", unverified: "미검증 ", off: "OFF " };
const count = (state) => results.filter((r) => r.state === state).length;
const [passes, fails, unverifieds, offs] = [count("pass"), count("fail"), count("unverified"), count("off")];
const lines = results.map((r) => `${LABEL[r.state]}${r.name}${r.detail ? " — " + r.detail : ""}`);
console.log(lines.join("\n"));
const tally = `충족 ${passes} / 미검증 ${unverifieds} / 실패 ${fails}`;
const headline = fails
? `**${fails}건 실패 — 배포 금지**`
: unverifieds
? `**검증 미완료 — 미검증 ${unverifieds}건(충족으로 세지 않는다). 아래 "미검증" 줄의 도구·입력을 갖춰 다시 돌리거나, 항목과 이유를 보고에 적는다**`
: "**전 항목 통과 — 배포 가능**";
let reportNote = "리포트 미작성";
if (WRITE_REPORT) {
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
const reportFile = path.join(OUT_DIR, "gate-report.md");
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(reportFile, `# 게이트 리포트 — ${stamp}\n\n${headline}\n\n집계: ${tally} (꺼짐 ${offs})\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`);
reportNote = `${path.relative(CWD, reportFile) || reportFile} 생성`;
}
const verdict = fails ? `게이트 실패(${fails})` : unverifieds ? "검증 미완료" : "게이트 통과";
console.log(`\n${verdict} — ${tally} (꺼짐 ${offs}) — ${reportNote}`);
process.exit(fails ? 1 : STRICT && unverifieds ? 3 : 0);