#!/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(/]+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(/