stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 없다"를 잡지 못한다. 미정의 변수는 선언 전체를 무효로 만드는데 콘솔 에러가 없어 조용히 남는다. 검사기를 심자마자 v0.9.1 의 실제 결함이 나왔다. 게이트 신설 - tools/integrity.mjs — L0 무결성. CSS 커스텀 프로퍼티 참조, 상대경로, 한글 조판 필수 규칙(keep-all·한글 폰트 지정), 슬롭 지문, 스크롤 리스너를 검사. verify.mjs 체인에 편입해 실패 시 배포를 막는다. 폴백이 있는 var(--x, 기본값) 은 선택적 주입이므로 미정의로 세지 않는다. - tools/showcase-audit.mjs — 320/390/768/1440 실렌더 감사. 가로 스크롤·오버플로· 컨트롤 줄바꿈·대비 AA·왼쪽 인셋·h1 개수·히어로 줄 수·깨진 이미지·콘솔 에러. 반투명 배경을 합성해 대비를 재고, WebGL 페이지를 위해 swiftshader 로 렌더한다. - html-validate 대상을 두 페이지에서 public/work 전체로 넓혔다. 수리한 결함 - 미정의 변수 64건: --dur-fast·--radius-xs 가 정의된 적이 없어 cask-archive, cheongon-care, materia-interior 의 transition 선언이 통째로 무효였다. 새 토큰을 만들지 않고 기존 --dur-quick·--radius 로 매핑했다 — 토큰을 늘리면 radius 어휘가 4종이 되어 하드 게이트 3에 걸린다. - 한글 조판: lumina-optics·pulsegate·synapse-bci 가 word-break: keep-all 없이 IBM Plex Sans 만 지정해 한글이 시스템 기본으로 떨어지고 있었다. 같은 계열의 IBM Plex Sans KR 을 스택에 넣고 keep-all 을 추가했다. - 슬롭 지문 19건: text-transform: uppercase 로 만든 영문 대문자 eyebrow 를 한글 문장으로 바꾸고, 대문자용으로 잡아둔 넓은 자간과 모노 서체도 되돌렸다. - 하드 게이트 9: materia-interior/hannam-duplex.html 의 100vh → 100dvh. 이 파일은 어디서도 참조되지 않으면서 인라인 hex 까지 갖고 있어 토큰 기반으로 다시 썼다. - 접근성 11건: label 하나가 output 과 input 둘에 걸리던 것 5건(font-foundry, lumina-optics), dl > div > div > dt 4건(muwol-coffee), div 에 붙은 aria-label 2건(overtone-sound, pulsegate). 측정기 자체의 오류 둘도 고쳤다 — 눈에 보이지 않는 .sr-only 를 대비 실패로 잡던 것과, overflow-x: auto 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면 통과도 실패도 의미가 없다.
69 lines
3.8 KiB
JavaScript
69 lines
3.8 KiB
JavaScript
// QA 게이트 — L0~L5 전 계층 실행. 1건 실패라도 있으면 exit 1 (배포 금지).
|
|
// 사용: node tools/verify.mjs / npm run verify
|
|
// 기준 화면 갱신: node tools/visual.mjs --update-baseline 후 커밋.
|
|
import { spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
|
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const root = path.resolve(here, "..");
|
|
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
|
|
const steps = [
|
|
{
|
|
layer: "L0-정적/stylelint",
|
|
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),
|
|
},
|
|
{
|
|
layer: "L0-정식/html-validate",
|
|
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html"], { cwd: root, encoding: "utf8", shell: true }),
|
|
},
|
|
{
|
|
// 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도
|
|
// 잡지 못한다. 실측 사고: --dur-fast 미정의로 transition 선언 11개가 통째로 무효였다.
|
|
layer: "L0-무결성/변수·경로·조판",
|
|
cmd: () => spawnSync(process.execPath, ["tools/integrity.mjs"], { cwd: root, encoding: "utf8" }),
|
|
},
|
|
{ layer: "L1-단위/계산로직", cmd: () => spawnSync(process.execPath, ["tools/unit/calc.test.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
{ layer: "L2-기능/감사", cmd: () => spawnSync(process.execPath, ["tools/audit.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
{ layer: "L2-불변식/렌더", cmd: () => spawnSync(process.execPath, ["tools/invariants.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
{ layer: "L3-시각회귀/diff", cmd: () => spawnSync(process.execPath, ["tools/visual.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
{ layer: "L4-WebKit", cmd: () => spawnSync(process.execPath, ["tools/webkit.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
{ layer: "L5-탐색/키보드", cmd: () => spawnSync(process.execPath, ["tools/exploratory.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
{ layer: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) },
|
|
];
|
|
|
|
const report = [];
|
|
const t0 = Date.now();
|
|
let failed = 0;
|
|
for (const step of steps) {
|
|
const s = Date.now();
|
|
const r = step.cmd();
|
|
const ok = r.status === 0;
|
|
if (!ok) failed++;
|
|
const out = (r.stdout || "") + (r.stderr || "");
|
|
const passLine = (out.match(/PASS\s+[^\n]/g) || []).length;
|
|
const failLines = (out.match(/FAIL\s+[^\n]/g) || []).length;
|
|
const tail = out.trim().split("\n").slice(-3).join(" | ").slice(0, 180);
|
|
report.push({ layer: step.layer, ok, ms: Date.now() - s, passLine, failLines, tail });
|
|
console.log(`${ok ? "✓" : "✗"} ${step.layer} (${((Date.now() - s) / 1000).toFixed(1)}s)${passLine ? ` — PASS ${passLine}` : ""}${failLines ? ` FAIL ${failLines}` : ""}`);
|
|
if (!ok) console.log(" " + tail);
|
|
}
|
|
|
|
const totalS = ((Date.now() - t0) / 1000).toFixed(1);
|
|
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
|
const md = [
|
|
`# 검증 리포트 — ${stamp}`,
|
|
``,
|
|
`게이트: ${failed ? `**${failed}개 계층 실패 — 배포 금지**` : "**전 계층 통과 — 배포 가능**"} · 총 ${totalS}초`,
|
|
``,
|
|
`| 계층 | 결과 | 항목 | 소요 |`,
|
|
`|---|---|---|---|`,
|
|
...report.map((r) => `| ${r.layer} | ${r.ok ? "PASS" : "**FAIL**"} | ${r.failLines ? `FAIL ${r.failLines}` : `PASS ${r.passLine || "—"}`} | ${(r.ms / 1000).toFixed(1)}s |`),
|
|
``,
|
|
...report.filter((r) => !r.ok).map((r) => `## ${r.layer} 실패 상세\n\n\`\`\`\n${r.tail}\n\`\`\``),
|
|
].join("\n");
|
|
fs.writeFileSync(path.join(here, "verify-report.md"), md);
|
|
console.log(`\n${failed ? `게이트 실패(${failed}계층)` : "게이트 통과"} — 리포트: tools/verify-report.md`);
|
|
process.exit(failed ? 1 : 0);
|