#!/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(/]+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 없음( 로 연결된 로컬 파일만 읽는다 — 인라인