test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构

- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
This commit is contained in:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+22 -4
View File
@@ -26,12 +26,26 @@ const PAGES = [
{ path: '/contact', name: '联系我们' },
];
// 负 PID = 杀整个进程组:`npm run preview` 只是包装进程,真正 LISTEN :3000 的是 next-server 孙进程;
// 只 kill 包装进程会让孙进程被 init 收养继续占端口,下一轮 E2E / Lighthouse 会静默复用它。
let serverStopped = false;
function stopServer(server) {
if (!server || !server.pid || serverStopped) return;
serverStopped = true;
try {
process.kill(-server.pid, 'SIGTERM');
} catch {
/* 进程组已自行退出 */
}
}
function startServer() {
return new Promise((resolve, reject) => {
const server = spawn('npm', ['run', 'preview'], {
cwd: path.resolve(__dirname, '..'),
stdio: 'pipe',
shell: true,
detached: true,
});
server.stdout.on('data', (data) => {
@@ -58,8 +72,11 @@ async function waitForServer(url, retries = 30) {
const res = await fetch(url, { method: 'HEAD' });
if (res.ok || res.status === 404) return;
} catch {
await new Promise((r) => setTimeout(r, 1000));
// 服务未起:连接被拒
}
// 就绪等待必须每轮都退避:启动期返回 503/500 等非 ok 状态时,
// 若只在 catch 里 sleep,30 次重试会在毫秒内跑完并误报「服务未就绪」。
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Server at ${url} did not become ready in time`);
}
@@ -137,14 +154,15 @@ async function runAudit() {
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
console.log(`\n📝 详细报告已保存: ${reportPath}`);
// process.exit() 不会执行 finally,所以两处出口都得自己收服服务再退出。
stopServer(server);
process.exit(totalViolations > 0 ? 1 : 0);
} catch (error) {
console.error('审计执行失败:', error);
stopServer(server);
process.exit(1);
} finally {
if (server) {
server.kill('SIGTERM');
}
stopServer(server);
}
}
+283
View File
@@ -0,0 +1,283 @@
// 全站 axe 节点计数门禁(验收 §8-⑤ 的永久化版本)。
//
// 来源:本文件由 `docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs` 平移而来(2026-09-23),
// 行为口径不变:同一份路由清单在 **chromium + firefox** × **light + dark** 四个组合下逐页跑两遍 axe-core
// 1) tag 通道 `runOnly:{type:'tag',values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}`
// 2) 规则级通道 `runOnly:{type:'rules',values:EXTRA_RULES}`
// 第 2 遍是 `presentation-role-conflict` 的**唯一真实覆盖**(该规则属 `best-practice` tag,不在 tag 通道内),
// 也是 Lighthouse 那三条死断言删除后 `autocomplete-valid` / `svg-img-alt` 的兜底。
// 证据链:docs/acceptance/2026-09-21-gates/final-tree-results.md §⑤ 与「Lighthouse 的 3 条死断言」。
//
// 与一次性版本的关键差异:**门禁判定从「打印 PASSED」升级为「exit code」**,并新增分母自证断言,
// 使「悄悄不再覆盖」无法以绿灯形式通过(CI 见 Jenkinsfile 的 ♿♿ 阶段)。
//
// 用法:
// npm run check:axe:routes # 生成路由清单(默认 /tmp/axe-routes.xml)
// npm run check:axe # 用该清单扫描并判定
// 环境变量:BASE(默认 http://localhost:3100,须是 `node dist/standalone/server.js` 起的生产服务,
// 非 dev server)、SITEMAP(路由清单 XML)、OUT(证据 JSON 输出目录,默认 docs/acceptance/2026-09-21-axe,
// 历史证据仍留在这里)。
//
// 退出码:0 通过 / 1 门禁判红(violation 节点 > 0、主题或底色不匹配、规则级通道分母不闭合)/
// 2 输入不可用(路由清单缺失或解析出 0 条)。
import { chromium, firefox } from 'playwright';
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, isAbsolute, join, resolve } from 'path';
// 本脚本从证据目录 docs/acceptance/2026-09-21-axe/ 搬到 scripts/accessibility/,cwd 相对路径一律改为
// 仓库根相对,这样无论从哪个目录调用都指向同一份 axe-core 与同一处证据目录(行为等价,只是不再依赖 cwd)。
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const repoPath = (p) => (isAbsolute(p) ? p : resolve(REPO_ROOT, p));
const BASE = process.env.BASE || 'http://localhost:3100';
const OUT = repoPath(process.env.OUT || 'docs/acceptance/2026-09-21-axe');
const SITEMAP = repoPath(process.env.SITEMAP || '/tmp/axe-routes.xml');
const AXE_SCRIPT = repoPath('node_modules/axe-core/axe.min.js');
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
// Lighthouse 12.6.1 不包装这三条 axe 规则,且 presentation-role-conflict 属 best-practice tag(不在 TAGS 内)
// ⇒ 只有这里的规则级 runOnly 才真正测到它们;证据链见 docs/acceptance/2026-09-21-gates/final-tree-results.md
//
// 两条数组刻意分开:
// CANONICAL_EXTRA_RULES = 门禁**要求被判定**的三条规则(绝对下限,写死在此)
// EXTRA_RULES = 实际传给 axe 的 runOnly 列表(默认等于下限)
// 只删 EXTRA_RULES 会立即判红(分母不再等于 路由 × 组合 × 3 条下限),删 CANONICAL 则需要改动带注释的门禁契约本身。
const CANONICAL_EXTRA_RULES = ['autocomplete-valid', 'presentation-role-conflict', 'svg-img-alt'];
const EXTRA_RULES = [...CANONICAL_EXTRA_RULES];
const ENGINES = [['chromium', chromium], ['firefox', firefox]];
const THEMES = ['light', 'dark'];
const COMBOS = ENGINES.length * THEMES.length; // 4 个「引擎 × 主题」组合
// 底色必须等于本站令牌值:未加载样式时所有元素都落在默认黑白底上,对比度会「意外通过」。
const EXPECTED_BG = { light: 'rgb(255, 255, 255)', dark: 'rgb(10, 14, 20)' };
// 逐页状态码期望:清单里绝大多数路由必须 2xx/3xx,`/_not-found` 是本站 404 页、**期望** 404。
// 为什么必须逐页判:404/500 页几乎没有可比对节点,`violationNodes` 仍为 0,而 `s.pages` 计数照加;
// 聚合保险丝 `okResponses > 0` 只在**全部**响应都非 2xx 时才炸 ⇒ 单个路由退化成 404 会被当「干净」计过。
// 证据:docs/acceptance/2026-09-21-axe/axe-evidence.json 实测 136 行 = 132×200 + 4×404(全部来自 /_not-found)。
const EXPECTED_404 = ['/_not-found'];
const statusOffenders = [];
function fatal(message, code = 2) {
console.error(`FATAL: ${message}`);
process.exit(code);
}
// 扫描中途抛错(浏览器启动失败、服务不可达、axe 注入失败)会以非 0 退出,这里只是把原因说清楚。
for (const ev of ['unhandledRejection', 'uncaughtException']) {
process.on(ev, (err) => fatal(`扫描中断(${ev}):${err instanceof Error ? err.message : String(err)}`, 1));
}
if (!existsSync(AXE_SCRIPT)) {
fatal(`缺少 ${AXE_SCRIPT}(axe-core 未安装?先 npm ci)`, 2);
}
if (!existsSync(SITEMAP)) {
fatal(`路由清单 ${SITEMAP} 不存在。先跑 npm run check:axe:routes 生成清单`, 2);
}
const urls = [...new Set(
readFileSync(SITEMAP, 'utf8')
.split('<url>')
.slice(1)
.map((b) => (b.match(/<loc>([^<]+)<\/loc>/) || [])[1])
.map((loc) => {
try {
return new URL(loc).pathname;
} catch {
return null;
}
})
.filter(Boolean)
)];
// 空清单时下面的循环零次执行、计数器全为 0,「PASSED = true」是假绿,必须拒绝出证据。
if (urls.length === 0) {
fatal(`从 ${SITEMAP} 解析出 0 条路由,扫描等于没测,退出 2`, 2);
}
async function settle(page) {
await page.waitForTimeout(1200);
await page.evaluate(async () => {
let last = -1;
for (let i = 0; i < 12; i += 1) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise((r) => setTimeout(r, 180));
if (Math.round(window.scrollY) === last) break;
last = Math.round(window.scrollY);
}
window.scrollTo(0, 0);
});
await page.waitForTimeout(400);
}
const rows = [];
for (const [engineName, launcher] of ENGINES) {
const browser = await launcher.launch();
for (const theme of THEMES) {
const ctx = await browser.newContext({ colorScheme: theme });
await ctx.addInitScript((t) => {
localStorage.setItem('novalon-theme', t);
localStorage.setItem('novalon-cookie-preferences',
JSON.stringify({ necessary: true, analytics: false, functional: false, timestamp: '2026-09-21T00:00:00.000Z' }));
}, theme);
const page = await ctx.newPage();
for (const url of urls) {
const res = await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded', timeout: 45000 });
const statusCode = res ? res.status() : 0;
await settle(page);
const observedTheme = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme'));
await page.addScriptTag({ path: AXE_SCRIPT });
const tagRun = await page.evaluate((tags) => window.axe.run(document, {
runOnly: { type: 'tag', values: tags },
resultTypes: ['violations'],
}), TAGS);
const violations = tagRun.violations.map((v) => ({
id: v.id, impact: v.impact, nodes: v.nodes.length,
sample: v.nodes.slice(0, 3).map((n) => (n.target || []).join(' ')),
}));
// 规则级复跑:axe 遇到未知 rule id 会直接抛错;extraRulesChecked 断言三条规则确实参与了本页运行。
const ruleRes = await page.evaluate((rules) => window.axe.run(document, {
runOnly: { type: 'rules', values: rules },
}), EXTRA_RULES);
const ruleViolations = ruleRes.violations.map((v) => ({
id: v.id, impact: v.impact, nodes: v.nodes.length,
sample: v.nodes.slice(0, 3).map((n) => (n.target || []).join(' ')),
}));
const ruleIds = new Set([
...ruleRes.violations, ...ruleRes.passes, ...ruleRes.incomplete, ...ruleRes.inapplicable,
].map((r) => r.id));
const covered = CANONICAL_EXTRA_RULES.filter((id) => ruleIds.has(id));
const pageBg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
rows.push({
engine: engineName, theme, url, observedTheme, statusCode, pageBg,
bgMatchesToken: pageBg === EXPECTED_BG[theme],
violationRules: violations.length,
violationNodes: violations.reduce((a, v) => a + v.nodes, 0),
contrastNodes: violations.filter((v) => v.id === 'color-contrast').reduce((a, v) => a + v.nodes, 0),
extraRuleNodes: ruleViolations.reduce((a, v) => a + v.nodes, 0),
extraRulesChecked: ruleIds.size,
extraRulesCovered: covered,
extraRulesMissing: CANONICAL_EXTRA_RULES.filter((id) => !ruleIds.has(id)),
violations,
extraRuleViolations: ruleViolations,
});
console.log(`${engineName}/${theme} ${url} status=${statusCode} theme=${observedTheme} bg=${pageBg} rules=${violations.length} nodes=${rows.at(-1).violationNodes} contrast=${rows.at(-1).contrastNodes} extra=${rows.at(-1).extraRuleNodes}/${covered.length}/${rows.at(-1).extraRulesChecked}`);
}
await ctx.close();
}
await browser.close();
}
const summary = {};
for (const r of rows) {
const k = `${r.engine}/${r.theme}`;
summary[k] = summary[k] || {
pages: 0, themeMismatch: 0, bgMismatch: 0, contrastNodes: 0, violationNodes: 0,
extraRuleNodes: 0, extraRulePagesUnderCovered: 0, extraRuleCoveragePairs: 0,
non2xxResponses: 0, okResponses: 0, unexpectedStatus: 0,
};
summary[k].pages += 1;
summary[k].contrastNodes += r.contrastNodes;
summary[k].violationNodes += r.violationNodes;
summary[k].extraRuleNodes += r.extraRuleNodes;
summary[k].extraRuleCoveragePairs += r.extraRulesCovered.length;
// 两条都要满足:三条下限规则逐条判定过(缺一条即覆盖腐化),且通道本身没跑出预期之外的形状。
if (r.extraRulesMissing.length > 0 || r.extraRulesChecked !== EXTRA_RULES.length) {
summary[k].extraRulePagesUnderCovered += 1;
}
if (r.observedTheme !== r.theme) summary[k].themeMismatch += 1;
if (!r.bgMatchesToken) summary[k].bgMismatch += 1;
if (r.statusCode >= 200 && r.statusCode < 400) summary[k].okResponses += 1;
else summary[k].non2xxResponses += 1;
// 逐页状态码判据(独立于上面两条聚合计数)
const expects404 = EXPECTED_404.includes(r.url);
const statusMatches = expects404
? r.statusCode === 404
: r.statusCode >= 200 && r.statusCode < 400;
if (!statusMatches) {
summary[k].unexpectedStatus += 1;
statusOffenders.push(
`${k} ${r.url} status=${r.statusCode} expected=${expects404 ? '404' : '2xx/3xx'}`,
);
}
}
const expectedCombos = ENGINES.flatMap(([e]) => THEMES.map((t) => `${e}/${t}`));
const totalRuleChecks = rows.reduce((a, r) => a + r.extraRulesChecked, 0);
const totalCanonicalCoverage = rows.reduce((a, r) => a + r.extraRulesCovered.length, 0);
const expectedCanonicalCoverage = urls.length * COMBOS * CANONICAL_EXTRA_RULES.length;
const missingRuleUnion = [...new Set(rows.flatMap((r) => r.extraRulesMissing))];
// ---- 门禁断言(exit code 由此决定,不再只是打印) ----
const failures = [];
const push = (cond, msg) => { if (!cond) failures.push(msg); };
// 分母自证:缺了它,「少扫了几个组合」与「扫了且全绿」在证据上无法区分。
push(rows.length === urls.length * COMBOS,
`分母不闭合:rows=${rows.length},应为 ${urls.length} 路由 × ${COMBOS} 组合 = ${urls.length * COMBOS}`);
for (const combo of expectedCombos) {
const s = summary[combo];
if (!s) { push(false, `${combo} 组合完全缺失(浏览器未启动或中途中断)`); continue; }
push(s.pages === urls.length, `${combo} 只扫了 ${s.pages} 页,清单有 ${urls.length} 页`);
push(s.violationNodes === 0, `${combo} violationNodes=${s.violationNodes}(阈值 0)`);
push(s.contrastNodes === 0, `${combo} contrastNodes=${s.contrastNodes}(阈值 0)`);
push(s.extraRuleNodes === 0, `${combo} extraRuleNodes=${s.extraRuleNodes}(三条规则级判据,阈值 0)`);
push(s.extraRulePagesUnderCovered === 0,
`${combo} 有 ${s.extraRulePagesUnderCovered} 页的规则级通道未覆盖满 ${CANONICAL_EXTRA_RULES.length} 条下限规则(缺:${missingRuleUnion.join(', ') || '形状异常'})`);
push(s.extraRuleCoveragePairs === urls.length * CANONICAL_EXTRA_RULES.length,
`${combo} 规则级覆盖 ${s.extraRuleCoveragePairs} ≠ ${urls.length} 路由 × ${CANONICAL_EXTRA_RULES.length} 规则`);
push(s.themeMismatch === 0, `${combo} themeMismatch=${s.themeMismatch}(以为在量深色其实还是浅色)`);
push(s.bgMismatch === 0, `${combo} bgMismatch=${s.bgMismatch}(底色不等于令牌值 ⇒ 样式未加载 ⇒ 对比度「意外达标」)`);
// 保险丝:整组全非 2xx ⇒ 服务或清单不对,扫的是 404 页而不是本站页面。
push(s.okResponses > 0, `${combo} 所有响应都非 2xx(BASE=${BASE} 不是可用的生产服务,或清单已过期)`);
push(s.unexpectedStatus === 0,
`${combo} 有 ${s.unexpectedStatus} 页状态码不符期望(404/500 页会被 axe 判为「无违规」且仍计入 pages 分母;期望见 EXPECTED_404)`);
}
// offenders 明细直接进判据文本(而非只打 stdout):本仓曾因 line reporter 不带 stdout
// 而在并发失败时「查不到规则名」,同理这里若不落进 failures 就查不到是哪一条路由 404。
if (statusOffenders.length > 0) {
push(false, `逐页状态码断言失败 ${statusOffenders.length} 处:\n ${statusOffenders.join('\n ')}`);
}
push(totalRuleChecks === urls.length * COMBOS * EXTRA_RULES.length,
`规则级通道总量 ${totalRuleChecks} ≠ ${urls.length}×${COMBOS}×${EXTRA_RULES.length} = ${urls.length * COMBOS * EXTRA_RULES.length}`);
// 绝对下限的分母闭合:三条规则 × 每一页 × 每一个组合,一个都不能少(0 违规 ≠ 没测到)。
push(totalCanonicalCoverage === expectedCanonicalCoverage,
`规则级覆盖 ${totalCanonicalCoverage} ≠ ${expectedCanonicalCoverage}` +
`(= ${urls.length} 路由 × ${COMBOS} 组合 × ${CANONICAL_EXTRA_RULES.length} 条下限规则 ${CANONICAL_EXTRA_RULES.join(', ')})`);
const artifact = {
generatedAt: new Date().toISOString(),
base: BASE,
axeCore: JSON.parse(readFileSync(repoPath('node_modules/axe-core/package.json'), 'utf8')).version,
routeSource: `${SITEMAP} → ${urls.length} 条路由(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)`,
routeCount: urls.length,
routes: urls,
combinations: expectedCombos,
tags: TAGS,
extraRules: EXTRA_RULES,
requiredRuleCoverage: CANONICAL_EXTRA_RULES,
thresholds: {
colorContrastNodes: 0, violationNodes: 0, themeMismatch: 0, bgMismatch: 0,
extraRuleNodes: 0, extraRulePagesUnderCovered: 0,
rows: urls.length * COMBOS,
totalExtraRuleChecks: urls.length * COMBOS * EXTRA_RULES.length,
requiredRuleCoverageTotal: expectedCanonicalCoverage,
},
totalExtraRuleChecks: totalRuleChecks,
expectedExtraRuleChecks: urls.length * COMBOS * EXTRA_RULES.length,
totalRequiredRuleCoverage: totalCanonicalCoverage,
expectedRequiredRuleCoverage: expectedCanonicalCoverage,
passed: failures.length === 0,
failures,
summary,
rows,
};
mkdirSync(OUT, { recursive: true });
writeFileSync(`${OUT}/axe-evidence.json`, JSON.stringify(artifact, null, 2));
console.log('\nSUMMARY', JSON.stringify(summary, null, 2));
console.log('PASSED =', artifact.passed);
if (!artifact.passed) {
console.error(`\nFAIL: axe 节点计数门禁 ${failures.length} 条判据未通过(证据已写入 ${OUT}/axe-evidence.json)`);
for (const f of failures) console.error(` ✗ ${f}`);
process.exit(1);
}
+165
View File
@@ -0,0 +1,165 @@
// 全站页面清单生成器(`check:axe` 的分母来源)。
//
// 来源:本文件由 `docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs` 平移而来(2026-09-23),口径不变:
// 从 BASE 起 BFS 跟随同源链接,并与 sitemap / 预渲染产物取并集 —— 因为 `src/app/sitemap.ts` 只列
// 「希望被收录」的页面,404 与根错误兜底页天然不在其中(见 docs/lessons-learned.md §5.24)。
//
// 用法(零参数即可跑通,Jenkins 的 ♿♿ 阶段就是这么调的):
// BASE=http://localhost:3100 npm run check:axe:routes
// 环境变量:
// BASE 生产服务地址(默认 http://localhost:3100;须是 `node dist/standalone/server.js`,不是 dev server)
// SEED 清单种子:既可以是本地 sitemap.xml 路径,也可以是 http(s) URL(默认取 `${BASE}/sitemap.xml`)
// MANIFEST 预渲染产物目录(默认 dist/standalone/dist/server/app),存在才参与并集
// OUT 输出的路由清单 XML(默认 /tmp/axe-routes.xml,即 axe-node-count.mjs 的默认 SITEMAP)
// MAX_PAGES BFS 上限(默认 200)
// 退出码:0 成功 / 2 清单为空或 BASE 完全不可达(宁可不产出,也不要让门禁扫到 0 页还判绿)。
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// 搬到 scripts/accessibility/ 后不再依赖 cwd:相对路径按仓库根解析。
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const repoPath = (p) => (isAbsolute(p) ? p : resolve(REPO_ROOT, p));
const BASE = process.env.BASE ?? 'http://localhost:3100';
const SEED = process.env.SEED ?? `${BASE}/sitemap.xml`;
const OUT = repoPath(process.env.OUT ?? '/tmp/axe-routes.xml');
const MANIFEST = repoPath(process.env.MANIFEST ?? 'dist/standalone/dist/server/app');
const MAX_PAGES = Number(process.env.MAX_PAGES ?? 200);
const CONCURRENCY = 5;
const origin = new URL(BASE).origin;
const found = new Set();
const visited = new Set();
function normalize(raw) {
let u;
try {
u = new URL(raw, BASE);
} catch {
return null;
}
if (u.origin !== origin) return null;
if (u.search || u.hash) return null;
const p = decodeURIComponent(u.pathname);
if (/\.(svg|png|jpe?g|webp|avif|ico|css|js|mjs|txt|xml|json|woff2?|mp4|pdf|zip)$/i.test(p)) return null;
if (p.startsWith('/api/') || p.startsWith('/_next/') || p.startsWith('/admin')) return null;
if (p === '/_global-error') return null; // 框架内置 500 文档,见下方 MANIFEST 段的按内容判定
if (p.includes('/uploads/')) return null;
return p === '/' ? '/' : p.replace(/\/+$/, '') || '/';
}
async function readSeed() {
if (!SEED) return '';
if (/^https?:\/\//i.test(SEED)) {
try {
const res = await fetch(SEED);
if (!res.ok) {
console.warn(`WARN: 种子 sitemap ${SEED} 返回 ${res.status},仅用预渲染产物 + BFS 作为清单来源`);
return '';
}
return await res.text();
} catch (err) {
console.warn(`WARN: 种子 sitemap ${SEED} 不可达(${err instanceof Error ? err.message : err}),仅用预渲染产物 + BFS`);
return '';
}
}
return readFile(repoPath(SEED), 'utf8'); // 本地文件路径:读不到就抛错,不静默降级
}
for (const m of (await readSeed()).matchAll(/<loc>([^<]+)<\/loc>/g)) {
const n = normalize(m[1]);
if (n) found.add(n);
}
// 预渲染产物清单:sitemap 只收静态页 + 已发布 slug,站内无入口的孤儿页不在其中。
// 构建产物里的 .html 才是「实际会渲染的公开页面」全集,故与 sitemap 取并集。
async function walk(dir) {
const out = [];
for (const ent of await readdir(dir, { withFileTypes: true })) {
const p = `${dir}/${ent.name}`;
if (ent.isDirectory()) out.push(...(await walk(p)));
else if (ent.name.endsWith('.html')) out.push(p);
}
return out;
}
let manifestCount = 0;
let skippedInternal = 0;
if (existsSync(MANIFEST)) {
for (const file of await walk(MANIFEST)) {
// Next.js 为根错误路径预渲染的是**框架内置 500 文档**(`<html id="__next_error__">`,零应用标记、
// 无 lang、深色落到浏览器默认画布),它不是本站编写的路由,扫它等于测框架模板。
// 本站确实提供了 src/app/global-error.tsx(佐证:dist/.../chunks/ssr/src_app_global-error_tsx_*.js 内含本站文案),
// 所以按**内容**判定而不是按名字硬编码:一旦框架把本站组件预渲染到此路径,条件不再命中,路由自动回到清单。
if (/<html[^>]*id="__next_error__"/.test(await readFile(file, 'utf8'))) {
skippedInternal++;
continue;
}
const rel = relative(MANIFEST, file).replace(/\.html$/, '');
const path = rel === 'index' ? '/' : `/${rel.replace(/\/index$/, '')}`;
const n = normalize(path);
if (n && !found.has(n)) manifestCount++;
if (n) found.add(n);
}
} else {
console.warn(`WARN: 预渲染产物目录 ${MANIFEST} 不存在,清单将缺少孤儿页(先 npm run build)`);
}
let reachable = 0;
let frontier = [...found];
while (frontier.length && visited.size < MAX_PAGES) {
const batch = frontier.splice(0, CONCURRENCY * 4);
const results = await Promise.all(
batch.map(async (path) => {
if (visited.has(path)) return [];
visited.add(path);
try {
const res = await fetch(BASE + path);
if (!res.ok) return [];
reachable += 1;
const html = await res.text();
const links = new Set();
for (const m of html.matchAll(/<a\b[^>]*href="([^"]+)"/g)) {
const n = normalize(m[1]);
if (n && !visited.has(n)) links.add(n);
}
return [...links];
} catch {
return [];
}
})
);
for (const links of results) {
for (const l of links) {
if (!found.has(l)) {
found.add(l);
frontier.push(l);
}
}
}
}
const paths = [...found].sort();
// 两条「宁红不假绿」的保险丝:清单为空 ⇒ 门禁等于没测;一条都不可达 ⇒ BASE 起错了/没起。
// 两种情况下都**不写 OUT**,避免用空清单覆盖上一份可用清单。
if (paths.length === 0) {
console.error(`FATAL: 三源(${SEED} ∪ ${MANIFEST} ∪ BFS)并集为 0 条路由,不写出 ${OUT}`);
process.exit(2);
}
if (visited.size > 0 && reachable === 0) {
console.error(`FATAL: BASE=${BASE} 上 ${visited.size} 条候选路由全部不可达,清单不可信,不写出 ${OUT}`);
process.exit(2);
}
const xml =
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
paths.map((p) => ` <url><loc>${BASE}${p}</loc></url>`).join('\n') +
'\n</urlset>\n';
await writeFile(OUT, xml);
console.log(
`crawled visited=${visited.size} reachable=${reachable} urls=${paths.length} manifestExtra=${manifestCount} skippedInternalDocs=${skippedInternal} -> ${OUT}`
);
console.log(paths.join('\n'));
+9 -2
View File
@@ -95,17 +95,24 @@ report_and_exit() {
# ── 模式 1: 扫描目录 ──
if [ -n "$SCAN_DIR" ]; then
if [ ! -d "$SCAN_DIR" ]; then
[ "$QUIET" -eq 0 ] && echo "⚠ PR 目录不存在: ${SCAN_DIR}(跳过)" >&2
exit 0
echo "✘ PR 目录不存在: ${SCAN_DIR} —— 门禁根本没跑。退出 2 表示脚本自身/入参故障,不等于通过" >&2
exit 2
fi
scanned=0
while IFS= read -r f; do
[ -f "$f" ] || continue
scanned=$((scanned + 1))
body=$(read_pr_body "$f")
unchecked=$(printf '%s\n' "$body" | grep -c '^- \[ \]' || true)
if [ "$unchecked" -gt 0 ]; then
violations+=("$(basename "$f"): 存在 ${unchecked} 个未勾选子项——所有 checklist 子项须勾选后才可提交 PR")
fi
done < <(find "$SCAN_DIR" -name '*.md' -type f | sort)
if [ "$scanned" -eq 0 ]; then
echo "✘ ${SCAN_DIR} 内没有任何 *.md —— 0 个文件被检查不等于 0 个违规。退出 2" >&2
exit 2
fi
[ "$QUIET" -eq 0 ] && echo "(已扫描 ${scanned} 个 PR 描述文件)"
report_and_exit "PR 描述文件门禁通过:所有文件子项均已勾选" "PR 描述文件门禁未通过"
fi
@@ -0,0 +1,431 @@
/**
* `scripts/utils/check-motion-constraints.ts` 的回归测试。
*
* 这个门禁的存在理由(N-29 + N-22/N-24/A-6「永不可失败的门禁」),决定了测试的形状:
* 每条规则都必须**两头都被证明** ——
* ① 合规夹具必须判 0(否则门禁是「常红」,会把人训练成忽略它);
* ② 违规夹具必须判非 0,且**是它对应的那条规则**判红(否则门禁是「常绿」)。
* 再加一条元测试:断言四条规则的并集 == 声明的规则全集,任何一条规则变得不可触发即红。
*
* 跑法(jest 的 roots 是 src,故本目录需显式指定):npm run check:motion:test
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import {
MOTION_RULES,
EXIT_CLEAN,
EXIT_VIOLATION,
EXIT_UNMEASURABLE,
markTransitionLines,
parseDurationMs,
parseTailwindDuration,
runScan,
type RuleId,
} from '../check-motion-constraints';
const SCRIPT_PATH = path.resolve(__dirname, '..', 'check-motion-constraints.ts');
/** 合规令牌层:曲线允许集合与 --transition-* 档位都齐活(脚本从这里读允许集合,不硬编码) */
const CLEAN_CSS = `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--transition-slow: 450ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
--card-transition: transform var(--transition-normal) var(--ease-ink);
}
.card {
transition: color var(--transition-fast) var(--ease-ink);
}
.glow {
animation: pulse-soft 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
`;
const CLEAN_TSX = `
'use client';
import { motion } from 'framer-motion';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
export function Card() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, ease: EASE_OUT }}
className="transition-colors duration-150"
/>
);
}
`;
const CONFIG_JS = `
module.exports = {
theme: {
extend: {
animation: {
'fade-in-up': 'fadeInUp 0.7s var(--ease-ink) forwards',
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
},
},
},
};
`;
let workspace: string;
beforeAll(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-gate-'));
});
afterAll(() => {
fs.rmSync(workspace, { recursive: true, force: true });
});
/**
* 写一棵最小夹具树并按门禁口径跑一遍。
* 每次调用用独立子目录,避免用例间互相污染。
*/
function scanFixture(fixture: {
css?: string;
source?: string;
config?: string;
omitSrc?: boolean;
omitCss?: boolean;
omitConfig?: boolean;
}) {
const root = fs.mkdtempSync(path.join(workspace, 'case-'));
fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true });
if (!fixture.omitCss) {
fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), fixture.css ?? CLEAN_CSS, 'utf8');
}
if (!fixture.omitSrc) {
fs.writeFileSync(
path.join(root, 'src', 'components', 'card.tsx'),
fixture.source ?? CLEAN_TSX,
'utf8',
);
}
if (!fixture.omitConfig) {
fs.writeFileSync(path.join(root, 'tailwind.config.js'), fixture.config ?? CONFIG_JS, 'utf8');
}
const result = runScan({
root,
srcDir: 'src',
cssFiles: ['src/app/globals.css'],
configFiles: ['tailwind.config.js'],
});
return {
root,
...result,
rules: new Set<RuleId>(result.violations.map((violation) => violation.rule)),
countOf: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule).length,
byRule: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule),
};
}
describe('parseDurationMs —— 时长单位口径', () => {
it('ms 与 s 都能解析,非时长形态返回 null', () => {
expect(parseDurationMs('180ms')).toBe(180);
expect(parseDurationMs(' 0.6s')).toBe(600);
expect(parseDurationMs('.28s')).toBeNull(); // 前导点写法不被承认,故不会静默算成 0
expect(parseDurationMs('24px')).toBeNull();
expect(parseDurationMs('var(--transition-fast)')).toBeNull();
});
});
describe('parseTailwindDuration —— Tailwind 时长类的单位口径', () => {
it('裸数字按 ms 解释,带单位则换算(duration-900 必须是 900ms,不能被静默忽略)', () => {
expect(parseTailwindDuration('150')).toBe(150);
expect(parseTailwindDuration('900')).toBe(900);
expect(parseTailwindDuration('0.6s')).toBe(600);
expect(parseTailwindDuration('1200ms')).toBe(1200);
});
});
describe('markTransitionLines —— 区分 framer transition 对象与其他 duration', () => {
it('只在 transition 对象内部为 true', () => {
const lines = [
'const variants = {',
' visible: {',
' transition: {',
' duration: 0.28,',
' },',
' },',
'};',
"toast('已保存', { duration: 3000 });",
];
// 只有 transition 对象自身(声明行 + 其内部行)为 true;外层对象与末尾的 sonner toast 都不算
expect(markTransitionLines(lines)).toEqual([false, false, true, true, false, false, false, false]);
});
});
describe('合规夹具:门禁必须判 0(防「常红」)', () => {
it('干净令牌层 + 干净源码 → EXIT_CLEAN,且确实看到了文件', () => {
const result = scanFixture({});
expect(result.violations).toEqual([]);
expect(result.exitCode).toBe(EXIT_CLEAN);
expect(result.counts.sourceFiles).toBe(1);
expect(result.counts.durationLiterals).toBeGreaterThan(0);
expect(result.allowedCurves.length).toBeGreaterThanOrEqual(2);
});
it('infinite 循环动画与 !important reduced-motion 硬开关按契约豁免', () => {
const result = scanFixture({});
expect(result.countOf('entrance-cap')).toBe(0);
expect(result.countOf('css-transition-token')).toBe(0);
});
it('*-delay 是延迟/错落而非时长:800ms 的 animation-delay 不判 entrance-cap', () => {
// 回归:首轮实现把 .animation-delay-800 误判成超上限(stagger 属 CONTEXT.md:78 的另一条口径)
const result = scanFixture({
css: `${CLEAN_CSS}\n.animation-delay-800 { animation-delay: 800ms; }\n.slow-in { transition-delay: 900ms; }\n`,
});
expect(result.countOf('entrance-cap')).toBe(0);
expect(result.countOf('css-transition-token')).toBe(0);
});
it('允许集合来自 CSS 令牌层而非硬编码:自定义曲线声明后被使用即合规', () => {
const result = scanFixture({
css: `
:root {
--transition-fast: 200ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
--ease-bespoke: cubic-bezier(0.1, 0.2, 0.3, 0.4);
}
`,
source: `const EASE_CUSTOM = [0.1, 0.2, 0.3, 0.4] as const;
export const C = () => <motion.div transition={{ duration: 0.2, ease: [0.1, 0.2, 0.3, 0.4] }} />;
`,
});
expect(result.violations).toEqual([]);
});
});
describe('违规夹具:每条规则都必须能判红(防「常绿」)', () => {
it('R1 token-band —— fast 超入场档 / instant 非反馈档', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 250ms;
--transition-fast: 400ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
}
`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
expect(result.countOf('token-band')).toBe(2);
expect(result.byRule('token-band').map((v) => v.token)).toEqual(['250ms', '400ms']);
});
it('R2 entrance-cap —— 令牌 1200ms / framer 1.4s / duration-900 类 / 配置串 1.1s', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--transition-gentle: 1200ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
}
.banner { transition: opacity var(--transition-gentle) var(--ease-ink); }
`,
source: `export const Bad = () => (
<motion.div
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
className="transition-all duration-900"
style={{ transition: 'opacity 0.9s var(--ease-ink)' }}
/>
);
`,
config: `module.exports = { theme: { extend: { animation: { 'slow-in': 'fadeIn 1.1s var(--ease-ink) forwards' } } } };`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
// 令牌自身 + 经 var() 解析到该令牌的声明 + framer 1.4s + duration-900 类 + 内联 0.9s + 配置串 1.1s
expect(result.countOf('entrance-cap')).toBe(6);
expect(result.byRule('entrance-cap').map((violation) => violation.file)).toEqual([
'src/app/globals.css',
'src/app/globals.css',
'src/components/card.tsx',
'src/components/card.tsx',
'src/components/card.tsx',
'tailwind.config.js',
]);
});
it('R3 css-transition-token —— 裸 ms/s 字面量与悬空 var() 引用', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
}
.glow-border::before {
transition: opacity 0.6s var(--ease-ink);
}
.ghost { transition: transform var(--transition-ghost) var(--ease-ink); }
`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
expect(result.countOf('css-transition-token')).toBe(2);
expect(result.byRule('css-transition-token').map((v) => v.token)).toEqual(['0.6s', 'var(--transition-ghost)']);
});
it('R4 easing-palette —— 非令牌曲线(framer 数组 / 具名常量 / CSS / Tailwind 任意值)与 --ease-ink 基准漂移', () => {
const result = scanFixture({
css: `
:root {
--transition-instant: 100ms;
--transition-fast: 180ms;
--transition-normal: 280ms;
--ease-ink: cubic-bezier(0.4, 0, 0.2, 1);
}
.bad { transition: opacity var(--transition-fast) cubic-bezier(0.9, 0, 0.1, 0.9); }
`,
source: `const EASE = [0.16, 1, 0.3, 1] as const;
export const Bad = () => (
<motion.div
transition={{ duration: 0.28, ease: [0.25, 1, 0.5, 1] }}
className="ease-[cubic-bezier(0.5, 0.5, 0.5, 0.5)]"
/>
);
`,
});
expect(result.exitCode).toBe(EXIT_VIOLATION);
// --ease-ink 基准 + CSS 写死曲线 + const EASE + ease: [] + Tailwind 任意值
expect(result.countOf('easing-palette')).toBe(5);
const files = result.byRule('easing-palette').map((violation) => violation.file);
expect(new Set(files)).toEqual(new Set(['src/app/globals.css', 'src/components/card.tsx']));
});
it('测试文件与被排除目录不参与判定(e2e 断言里出现违规是合规的)', () => {
const root = fs.mkdtempSync(path.join(workspace, 'excluded-'));
fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true });
fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8');
fs.writeFileSync(path.join(root, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
fs.writeFileSync(
path.join(root, 'src', 'components', 'card.test.tsx'),
'const EASE = [0.99, 0.98, 0.97, 0.96] as const;\n',
'utf8',
);
fs.writeFileSync(path.join(root, 'tailwind.config.js'), CONFIG_JS, 'utf8');
const result = runScan({
root,
srcDir: 'src',
cssFiles: ['src/app/globals.css'],
configFiles: ['tailwind.config.js'],
});
expect(result.exitCode).toBe(EXIT_CLEAN);
expect(result.counts.sourceFiles).toBe(1);
});
});
describe('元测试:规则可达性(N-22 / N-24 / A-6 的直接防线)', () => {
it('四条规则各自都有至少一个能触发它的夹具', () => {
const fixtures = [
scanFixture({ css: CLEAN_CSS.replace('--transition-fast: 180ms', '--transition-fast: 999ms') }),
scanFixture({ source: CLEAN_TSX.replace('duration: 0.28', 'duration: 2.8') }),
scanFixture({ css: `${CLEAN_CSS}\n.x { transition: opacity 0.6s var(--ease-ink); }` }),
scanFixture({ source: CLEAN_TSX.replace('[0.22, 1, 0.36, 1]', '[0.7, 0.7, 0.7, 0.7]') }),
];
const covered = new Set<RuleId>();
for (const fixture of fixtures) {
expect(fixture.exitCode).toBe(EXIT_VIOLATION);
for (const violation of fixture.violations) covered.add(violation.rule);
}
expect([...covered].sort()).toEqual([...MOTION_RULES.map((rule) => rule.id)].sort());
});
it('MOTION_RULES 声明的四条规则 id 唯一且与退出码语义一致', () => {
const ids = MOTION_RULES.map((rule) => rule.id);
expect(new Set(ids).size).toBe(ids.length);
expect([EXIT_CLEAN, EXIT_VIOLATION, EXIT_UNMEASURABLE]).toEqual([0, 1, 2]);
});
});
describe('「空扫描 ≠ 干净」:无法度量必须判 2', () => {
it('源码目录不存在 → 2', () => {
const result = scanFixture({ omitSrc: true });
// 目录仍在(夹具建了 src/app),故这里用空目录场景另证
expect(result.counts.sourceFiles).toBe(0);
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
});
it('CSS 令牌文件缺失 → 2', () => {
const result = scanFixture({ omitCss: true });
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
expect(result.blockers.join(' ')).toContain('CSS 令牌文件不存在');
});
it('令牌层解析不到任何 --ease-* / --transition-* → 2(不允许静默放行)', () => {
const result = scanFixture({ css: '.a { transition: opacity 200ms linear; }' });
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
expect(result.blockers.join(' ')).toContain('--ease-*');
});
it('tailwind.config.js 缺失 → 2', () => {
const result = scanFixture({ omitConfig: true });
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
expect(result.blockers.join(' ')).toContain('tailwind.config.js');
});
});
describe('CLI 退出码 plumbing(process.exit 真的接上了判定)', () => {
function runCli(root: string, extra: string[] = []) {
return spawnSync('npx', ['tsx', SCRIPT_PATH, '--root', root, ...extra], {
encoding: 'utf8',
cwd: path.resolve(__dirname, '..', '..', '..'),
});
}
it('合规夹具 0 / 违规夹具 1 / 目标缺失 2', () => {
const clean = fs.mkdtempSync(path.join(workspace, 'cli-clean-'));
fs.mkdirSync(path.join(clean, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(clean, 'src', 'components'), { recursive: true });
fs.writeFileSync(path.join(clean, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8');
fs.writeFileSync(path.join(clean, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
fs.writeFileSync(path.join(clean, 'tailwind.config.js'), CONFIG_JS, 'utf8');
expect(runCli(clean).status).toBe(EXIT_CLEAN);
const dirty = fs.mkdtempSync(path.join(workspace, 'cli-dirty-'));
fs.mkdirSync(path.join(dirty, 'src', 'app'), { recursive: true });
fs.mkdirSync(path.join(dirty, 'src', 'components'), { recursive: true });
fs.writeFileSync(
path.join(dirty, 'src', 'app', 'globals.css'),
`${CLEAN_CSS}\n.bad { transition: opacity 1.2s cubic-bezier(0.9, 0, 0.1, 0.9); }\n`,
'utf8',
);
fs.writeFileSync(path.join(dirty, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
fs.writeFileSync(path.join(dirty, 'tailwind.config.js'), CONFIG_JS, 'utf8');
const dirtyRun = runCli(dirty);
expect(dirtyRun.status).toBe(EXIT_VIOLATION);
expect(dirtyRun.stdout).toContain('css-transition-token');
const missing = fs.mkdtempSync(path.join(workspace, 'cli-missing-'));
expect(runCli(missing).status).toBe(EXIT_UNMEASURABLE);
}, 120000);
});
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env tsx
/**
* 品牌红「文字通道」契约 + 「透明度修饰符打在裸 var() 任意值上」死样式检查
*
* 本脚本守护两条机械可判定的规则(npm run check:brand-token,已并入 check:a11y):
*
* ── 规则 A:DESIGN.md 的 The Two-Channel Red Rule(DESIGN.md:154 / DESIGN.md:232)
* 底色用 `--color-brand`(暗黑不翻转),文字用 `--color-brand-ink`(暗黑翻至 #F87171)。
* 把 `--color-brand` 当文字色用是契约违反:亮色下 #FFFFFF 上 5.84:1(侥幸过 AA),
* 暗色下 --color-bg-primary (#0A0E14) 仅 3.31:1、--color-brand-bg (#2A1418) 仅 2.97:1,
* 均低于小字 WCAG AA 的 4.5:1 —— 该失败已由 axe(Playwright 移动 + Lighthouse 桌面)
* 双引擎在全站 62 页实测复现(ACCEPTANCE_REVIEW_2026-09-21 A-12 / R-3)。
*
* ── 规则 B:Tailwind 无法对「任意值形式的裸 var()」套用 /NN 透明度修饰符
* `bg-[var(--color-bg-section)]` 再加 `/60`、`border-[var(--color-border-primary)]` 加 `/50`、
* `group-hover:bg-[var(--color-brand-bg)]` 加 `/30` 这类写法 **编译后不生成任何 CSS 规则**
* (已用 `npx tailwindcss -i ./src/app/globals.css -o /tmp/x.css` 实测:0 条选择器;
* 对照 `.bg-bg-section\/60`、`.border-border-primary\/50` 各 1 条,正常生成)。
* 它同时满足三个特征:不报错、不进入产物、Code Review 里看起来完全合法 —— 即「静默死样式」。
* 正确写法:为该色建 `-rgb` 三元组 token(:root 与 html[data-theme='dark'] 都声明),
* 并在 tailwind.config.js 用 `rgb(var(--color-x-rgb) / <alpha-value>)` 注册,
* 然后写 token class:`bg-bg-section/60`、`border-border-primary/50`、`bg-brand-bg/30`。
* 注:不带 alpha 修饰符的 `bg-[var(--color-x)]` 是可正常编译的(本脚本不报),
* 规则 B 只锁定 `]/<alpha>` 这一形态。
*
* 违规判定见 FORBIDDEN_RULES;合法写法示例:`text-brand-ink`、`bg-brand/10`、`bg-bg-primary/80`。
*/
import fs from 'fs';
import path from 'path';
/** 扫描根目录(相对仓库根)与纳入扫描的源码扩展名 */
const SCAN_ROOT = 'src';
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.mdx'];
interface ForbiddenRule {
id: 'A' | 'B';
/** 人类可读的规则名 */
title: string;
pattern: RegExp;
/** 由命中 token 生成修复建议 */
suggestion: (token: string) => string;
/** 命中时的补充说明 */
footnote?: string;
}
/**
* 规则 A —— 只锁定「文字通道误用」:`--color-brand` 后必须紧跟 `)]` 才算命中,
* 因此 `--color-brand-ink` / `--color-brand-bg` / `--color-brand-hover` 不会误报;
* `bg-` / `border-` / `fill-` / `stroke-` / `ring-` 等底色通道也不在匹配范围内。
*
* 规则 B —— 只锁定「颜色类工具 + 裸 var() 任意值 + 透明度修饰符」。
* 关键约束(避免误伤可正常编译的写法):
* 1. `\[var\(` 紧跟工具名前缀 —— 排除 `bg-[rgba(var(--color-brand-rgb),0.2)]`(合法,有实际色值);
* 2. 必须命中 `/\d` 或 `/[…]` —— 排除 `bg-[var(--color-brand-bg)]`(合法,无修饰符);
* 3. token 名限 `--color-*` 族 —— 排除 `w-[var(--spacing-md)]/2` 之类非颜色任意值;
* 4. 允许多级 variant 前缀(`hover:` / `group-hover:` / `sm:` / `dark:` …)。
* fill / stroke 与已列十个工具同属 Tailwind 颜色工具族、同样会静默失效,故一并纳入。
*/
const ALPHA_SUFFIX = String.raw`(?:\/(?:\[[^\]]*\]|[0-9]+(?:\.[0-9]+)?))`;
const VARIANT_PREFIX = String.raw`(?:[a-zA-Z0-9_-]+:)*`;
const COLOR_UTILITY = 'bg|border|from|via|to|shadow|text|ring|divide|placeholder|fill|stroke';
const FORBIDDEN_RULES: ForbiddenRule[] = [
{
id: 'A',
title: 'Two-Channel Red Rule:文字通道禁用 --color-brand',
pattern: new RegExp(
String.raw`(?:[a-zA-Z0-9:_-]+:)?text-\[var\(--color-brand\)\](?:\/(?:\[[^\]]*\]|[0-9.]+))?`,
'g',
),
suggestion: (token) => {
const prefix = token.slice(0, token.indexOf('text-'));
return token.includes(']/')
? `${prefix}text-brand-ink/${token.split(']/')[1]}`
: `${prefix}text-brand-ink`;
},
footnote: '规则出处: DESIGN.md:154 / DESIGN.md:232。带 alpha 的 text-[var(--color-brand)]/NN 同时是死样式(编译后 0 条规则)。',
},
{
id: 'B',
title: '死样式:Tailwind 不能对裸 var() 任意值套用透明度修饰符',
pattern: new RegExp(
String.raw`(${VARIANT_PREFIX})(?:${COLOR_UTILITY})-\[var\(--color-[^\]\s]*\)\]${ALPHA_SUFFIX}`,
'g',
),
suggestion: (token) => {
const [, utility] = token.match(
new RegExp(String.raw`(?:${VARIANT_PREFIX})((?:${COLOR_UTILITY})-)`),
) ?? [];
const varName = token.match(/\[var\(--color-([^\]]+)\)\]/)?.[1];
const alpha = token.split(']/')[1];
if (!utility || !varName) return '<改用对应 token class>/<alpha>';
const prefix = token.slice(0, token.indexOf(utility));
// --color-bg-section -> bg-bg-section;--color-brand-bg -> bg-brand-bg;--color-ink -> text-ink
const colorKey = varName.replace(/^color-/, '');
return `${prefix}${utility}${colorKey}${alpha ? `/${alpha}` : ''}`;
},
footnote:
'改为 token class:先补 <name>-rgb 三元组(:root 与 html[data-theme=\'dark\'] 两版),' +
'再在 tailwind.config.js 注册 rgb(var(--color-<name>-rgb) / <alpha-value>),最后写 <util>-<color>/<alpha>。',
},
];
/**
* 已知死样式、但由并发清理任务负责下线的文件 —— 降级为 warning 而非 failure。
* 目的不是掩盖,而是让门禁在「他人正在删除」的窗口期仍可退出 0;
* 一旦文件仍存在,下方会打印待办修复;一旦文件消失,会提示从本清单移除,避免清单腐烂。
*/
const PENDING_CLEANUP_ALLOWLIST: Array<{ file: string; reason: string; fix: string }> = [
{
file: 'src/components/ui/loading-state.tsx',
reason: '无页面引用(仅自身测试导入),且 loading 组件族正由另一并发任务删除(loading-skeleton.tsx 已删除)',
fix: 'L137 `bg-[var(--color-bg-primary)]/80` → `bg-bg-primary/80`(token 已存在,无需新增)',
},
];
interface Violation {
file: string;
line: number;
token: string;
rule: ForbiddenRule;
suppressed: boolean;
}
function parseArgs(): string {
const args = process.argv.slice(2);
const dirIndex = args.indexOf('--dir');
if (dirIndex !== -1 && args[dirIndex + 1]) {
return args[dirIndex + 1]!;
}
return SCAN_ROOT;
}
function collectSourceFiles(absoluteDir: string, accumulator: string[]): void {
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
const entryPath = path.join(absoluteDir, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(entryPath, accumulator);
continue;
}
if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
accumulator.push(entryPath);
}
}
}
function isAllowlisted(relativeFile: string): boolean {
return PENDING_CLEANUP_ALLOWLIST.some((entry) => entry.file === relativeFile);
}
function scanFile(absoluteFile: string, repoRoot: string): Violation[] {
const violations: Violation[] = [];
const relativeFile = path.relative(repoRoot, absoluteFile);
const suppressed = isAllowlisted(relativeFile);
const lines = fs.readFileSync(absoluteFile, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => {
const seen = new Set<string>();
for (const rule of FORBIDDEN_RULES) {
rule.pattern.lastIndex = 0;
for (const match of line.matchAll(rule.pattern)) {
const token = match[0]!;
// 规则 A 已覆盖的 token 不再由规则 B 重复计数(两者命中同一处写法)
const coveredByEarlierRule = [...seen].some((previous) => previous.includes(token));
if (coveredByEarlierRule) continue;
seen.add(token);
violations.push({ file: relativeFile, line: index + 1, token, rule, suppressed });
}
}
});
return violations;
}
function main(): void {
const repoRoot = path.resolve(__dirname, '../..');
// resolve(而非 join)以同时支持 --dir 传入相对路径与绝对路径(如临时对照夹具目录)
const scanRoot = path.resolve(repoRoot, parseArgs());
console.log('\n🔴 品牌红双通道契约 + 裸 var() 透明度死样式 检查\n');
if (!fs.existsSync(scanRoot)) {
console.error(`❌ 扫描目录不存在: ${path.relative(repoRoot, scanRoot)}`);
process.exit(1);
}
const files: string[] = [];
collectSourceFiles(scanRoot, files);
files.sort();
const violations: Violation[] = [];
for (const file of files) {
violations.push(...scanFile(file, repoRoot));
}
const blocking = violations.filter((v) => !v.suppressed);
const suppressed = violations.filter((v) => v.suppressed);
for (const violation of blocking) {
console.log(`❌ [规则 ${violation.rule.id}] ${violation.file}:${violation.line} ${violation.token}`);
console.log(` 改为 ${violation.rule.suggestion(violation.token)}`);
}
for (const entry of PENDING_CLEANUP_ALLOWLIST) {
const absolute = path.join(repoRoot, entry.file);
if (!fs.existsSync(absolute)) {
console.log(`ℹ️ 白名单条目已失效(文件不存在,请从本脚本移除): ${entry.file}`);
continue;
}
if (suppressed.some((v) => v.file === entry.file)) {
console.log(`\n⚠️ [已降级为 warning] ${entry.file} —— ${entry.reason}`);
console.log(` 待办修复: ${entry.fix}`);
}
}
console.log('\n📊 检查摘要');
console.log(` 扫描目录: ${path.relative(repoRoot, scanRoot)}`);
console.log(` 扫描文件: ${files.length}`);
console.log(` 违规处数: ${blocking.length}(另有 ${suppressed.length} 处在清理白名单内,已降级)`);
if (blocking.length > 0) {
const ruleIds = [...new Set(blocking.map((v) => v.rule.id))].sort();
for (const id of ruleIds) {
const rule = FORBIDDEN_RULES.find((r) => r.id === id)!;
console.log(`\n⚠️ 规则 ${id}: ${rule.title}`);
console.log(` ${rule.footnote ?? ''}`);
}
console.log('\n❌ 存在契约违反或静默死样式!');
process.exit(1);
}
console.log('\n✅ 文字通道使用 --color-brand-ink,且无「裸 var() + 透明度」死样式!');
process.exit(0);
}
main();
+222 -49
View File
@@ -1,62 +1,235 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { meetsWCAGStandard } from '../../src/lib/color-contrast';
interface ColorPair {
name: string;
/**
* 对比度门禁(验收 A-11-1 重写)。
*
* 旧实现是 7 组硬编码十六进制(全部 #FFFFFF 底),既不读令牌表也不含暗色配对 ——
* 主题令牌改了它仍对陈旧色值报绿,因此全站 62 页的暗色违规能从这道门禁底下漏过。
* 现在:色值一律从 globals.css 的两个主题根解析,缺令牌即失败(不允许静默跳过)。
*/
const THEME_BLOCKS = [
{ theme: 'light', selector: ':root' },
{ theme: 'dark', selector: "html[data-theme='dark']" },
] as const;
interface Pair {
foreground: string;
background: string;
/** 前景 alpha(0-1),用于检验带 alpha 修饰类的正文(如 text-text-secondary 80%) */
alpha?: number;
textSize?: 'normal' | 'large';
}
const criticalColorPairs: ColorPair[] = [
{ name: 'Primary text on primary background', foreground: '#1C1C1C', background: '#FFFFFF' },
{ name: 'Secondary text on primary background', foreground: '#3D3D3D', background: '#FFFFFF' },
{ name: 'Tertiary text on primary background', foreground: '#4A4A4A', background: '#FFFFFF' },
{ name: 'Muted text on primary background', foreground: '#6B6B6B', background: '#FFFFFF' },
{ name: 'Brand primary on white', foreground: '#C41E3A', background: '#FFFFFF' },
{ name: 'Brand primary on brand bg', foreground: '#C41E3A', background: '#FEF2F4' },
{ name: 'Link on hover', foreground: '#C41E3A', background: '#FFFFFF' },
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token。 */
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token,两主题各解一次。 */
const REQUIRED_GROUPS: Array<{ label: string; pairs: Pair[] }> = [
{
label: '正文层级 × 页面底色',
pairs: [
{ foreground: '--color-text-primary', background: '--color-bg-primary' },
{ foreground: '--color-text-primary', background: '--color-bg-secondary' },
{ foreground: '--color-text-primary', background: '--color-bg-tertiary' },
{ foreground: '--color-text-secondary', background: '--color-bg-primary' },
{ foreground: '--color-text-secondary', background: '--color-bg-secondary' },
{ foreground: '--color-text-tertiary', background: '--color-bg-primary' },
{ foreground: '--color-text-muted', background: '--color-bg-primary' },
],
},
{
label: '品牌红文字通道(text-brand-ink 及 hover 档)',
pairs: [
{ foreground: '--color-brand-ink', background: '--color-bg-primary' },
{ foreground: '--color-brand-ink', background: '--color-bg-secondary' },
{ foreground: '--color-brand-ink', background: '--color-brand-bg' },
{ foreground: '--color-brand-ink-hover', background: '--color-bg-primary' },
{ foreground: '--color-brand-ink-hover', background: '--color-brand-bg' },
// 红底按钮的真实组合是 bg-brand + text-white(button.tsx:14,text-sm 属 normal 字号);
// --color-text-inverse 全仓 0 引用且暗色翻成墨色,不构成 UI 组合,故此处用字面量白。
{ foreground: '#FFFFFF', background: '--color-brand' },
],
},
{
label: 'alpha 修饰正文(带 /80 等修饰类的正文)',
pairs: [{ foreground: '--color-text-secondary', background: '--color-bg-primary', alpha: 0.8 }],
},
{
// 验收 A-12 残留:装饰序号/占位字(aria-hidden + ≥24px)。axe 的 color-contrast 不认 aria-hidden
// (实测 4.11.4 仍按可见文字判定),所以这类文字必须自己过 AA 大字 3:1,不能再靠低 alpha 淡化。
label: '大号装饰文本(text-text-hint)× 各级卡片底色',
pairs: [
{ foreground: '--color-text-hint', background: '--color-bg-primary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-secondary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-tertiary', textSize: 'large' },
{ foreground: '--color-text-hint', background: '--color-bg-elevated', textSize: 'large' },
],
},
{
label: '深色区块(不随主题翻转的独立 token)',
pairs: [
{ foreground: '--color-dark-text-primary', background: '--color-dark-bg' },
{ foreground: '--color-dark-text-secondary', background: '--color-dark-bg' },
{ foreground: '--color-dark-text-muted', background: '--color-dark-bg' },
{ foreground: '--color-brand-section-text', background: '--color-brand-section' },
],
},
];
function checkContrast() {
console.log('🎨 Color Contrast Check\n');
let failures = 0;
let passes = 0;
criticalColorPairs.forEach(pair => {
const result = meetsWCAGStandard(
pair.foreground,
pair.background,
'AA',
pair.textSize || 'normal'
);
const status = result.passes ? '✅ PASS' : '❌ FAIL';
const ratio = result.ratio.toFixed(2);
console.log(`${status} ${pair.name}`);
console.log(` Ratio: ${ratio}:1 (Required: ${result.requiredRatio}:1)`);
console.log(` FG: ${pair.foreground} | BG: ${pair.background}\n`);
if (result.passes) {
passes++;
} else {
failures++;
function extractBlock(css: string, selector: string): string {
const start = css.indexOf(`${selector} {`);
if (start < 0) throw new Error(`globals.css 中找不到主题根:${selector}`);
let depth = 0;
let cursor = css.indexOf('{', start);
const bodyStart = cursor + 1;
while (cursor < css.length) {
const ch = css[cursor];
if (ch === '{') depth += 1;
else if (ch === '}') {
depth -= 1;
if (depth === 0) break;
}
});
console.log(`\n📊 Summary:`);
console.log(` Total: ${criticalColorPairs.length}`);
console.log(` ✅ Passes: ${passes}`);
console.log(` ❌ Failures: ${failures}`);
if (failures > 0) {
console.log('\n⚠️ Some color pairs do not meet WCAG AA standards!');
process.exit(1);
} else {
console.log('\n✅ All color pairs meet WCAG AA standards!');
process.exit(0);
cursor += 1;
}
return css.slice(bodyStart, cursor);
}
checkContrast();
function parseTokens(block: string): Map<string, string> {
const tokens = new Map<string, string>();
for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
const name = match[1] as string;
const value = (match[2] as string).trim();
tokens.set(name, tokens.get(name) ?? value);
}
return tokens;
}
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
function parseColor(raw: string, tokens: Map<string, string>, depth = 0): Rgba {
if (depth > 5) throw new Error(`var() 递归超过 5 层:${raw}`);
const value = raw.startsWith('var(') ? resolveVar(raw, tokens) : raw;
if (value !== raw && value.startsWith('var(')) return parseColor(value, tokens, depth + 1);
const hex = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i)?.[1];
if (hex) {
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16),
a: 1,
};
}
const rgb = value.match(/^rgba?\(([^)]+)\)$/);
if (rgb) {
const parts = (rgb[1] as string).split(/[\s,]+/).map((p) => Number.parseFloat(p));
const [r, g, b, a] = [parts[0], parts[1], parts[2], parts[3]];
if ([r, g, b].some((n) => n === undefined || Number.isNaN(n))) throw new Error(`无法解析颜色:${value}`);
return { r: r ?? 0, g: g ?? 0, b: b ?? 0, a: a === undefined || Number.isNaN(a) ? 1 : a };
}
throw new Error(`无法解析颜色:${raw}`);
}
function resolveVar(raw: string, tokens: Map<string, string>): string {
const inner = raw.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/);
if (!inner) return raw;
const name = inner[1] as string;
const fallback = inner[2];
const value = tokens.get(name) ?? fallback;
if (value === undefined) throw new Error(`令牌未定义:${name}`);
return value.trim();
}
function composite(fg: Rgba, bg: Rgba): Rgba {
const a = fg.a;
return {
r: Math.round(fg.r * a + bg.r * (1 - a)),
g: Math.round(fg.g * a + bg.g * (1 - a)),
b: Math.round(fg.b * a + bg.b * (1 - a)),
a: 1,
};
}
function toHex({ r, g, b }: Rgba): string {
const hex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return `#${hex(r)}${hex(g)}${hex(b)}`.toUpperCase();
}
function resolve(tokenName: string, tokens: Map<string, string>): string {
if (tokenName.startsWith('#')) return tokenName;
const raw = tokens.get(tokenName);
if (raw === undefined) throw new Error(`主题令牌缺失:${tokenName}`);
return raw;
}
function check() {
const cssPath = join(process.cwd(), 'src', 'app', 'globals.css');
const css = readFileSync(cssPath, 'utf8');
let passes = 0;
const failures: string[] = [];
const missing: string[] = [];
for (const { theme, selector } of THEME_BLOCKS) {
const own = parseTokens(extractBlock(css, selector));
// 按 CSS 层叠语义取色:暗色块只声明需要翻转的令牌,其余沿用 :root。
// 不做层叠就会把「刻意不翻」的底色通道误报成令牌缺失(DESIGN.md 双通道红规则)。
const tokens = theme === 'dark' ? new Map([...parseTokens(extractBlock(css, ':root')), ...own]) : own;
console.log(`\n🎨 ${theme.toUpperCase()} 主题(${selector},${tokens.size} 个令牌)`);
for (const group of REQUIRED_GROUPS) {
console.log(` ${group.label}`);
for (const pair of group.pairs) {
let label = `${pair.foreground} on ${pair.background}${pair.alpha !== undefined ? ` @${pair.alpha * 100}%` : ''}`;
try {
const fgToken = parseColor(resolve(pair.foreground, tokens), tokens);
const bgToken = parseColor(resolve(pair.background, tokens), tokens);
const fg = pair.alpha === undefined ? fgToken : { ...fgToken, a: pair.alpha };
const composited = composite(fg, bgToken);
const bgHex = toHex(bgToken);
const fgHex = toHex(composited);
const result = meetsWCAGStandard(fgHex, bgHex, 'AA', pair.textSize ?? 'normal');
const ratio = result.ratio.toFixed(2);
if (result.passes) {
passes += 1;
console.log(` ✅ ${label} — ${ratio}:1`);
} else {
failures.push(`${theme} · ${label} = ${ratio}:1(需 ${result.requiredRatio}:1;${fgHex} on ${bgHex})`);
console.log(` ❌ ${label} — ${ratio}:1(需 ${result.requiredRatio}:1)`);
}
} catch (error) {
missing.push(`${theme} · ${label} — ${(error as Error).message}`);
console.log(` ❌ ${label} — ${(error as Error).message}`);
}
}
}
}
console.log(`\n📊 合计 ${passes + failures.length + missing.length} 组:✅ ${passes} / ❌ ${failures.length} 不达标 / ⚠️ ${missing.length} 令牌缺失`);
if (failures.length > 0) {
console.log('\n不达标:');
failures.forEach((f) => console.log(` · ${f}`));
}
if (missing.length > 0) {
console.log('\n令牌缺失(门禁视为失败,避免对着陈旧色值报绿):');
missing.forEach((m) => console.log(` · ${m}`));
}
if (failures.length > 0 || missing.length > 0) {
console.log('\n⚠️ 存在不满足 WCAG 2.1 AA 的令牌配对');
process.exit(1);
}
console.log('\n✅ 全部令牌配对满足 WCAG 2.1 AA');
process.exit(0);
}
check();
+135 -49
View File
@@ -9,27 +9,32 @@
* 3. 标题不为空
*/
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import path from 'path';
import { chromium } from "playwright";
import { spawn } from "child_process";
import path from "path";
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
const BASE_URL = process.env.E2E_BASE_URL || "http://localhost:3000";
const PAGES = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/products', name: '产品中心' },
{ path: '/products/erp', name: '产品详情-ERP' },
{ path: '/solutions', name: '解决方案' },
{ path: '/solutions/manufacturing', name: '解决方案详情-制造' },
{ path: '/services', name: '服务列表' },
{ path: '/services/software', name: '服务详情-软件开发' },
{ path: '/news', name: '新闻列表' },
{ path: '/contact', name: '联系我们' },
{ path: "/", name: "首页" },
{ path: "/about", name: "关于我们" },
{ path: "/products", name: "产品中心" },
{ path: "/products/erp", name: "产品详情-ERP" },
{ path: "/solutions", name: "解决方案" },
{ path: "/solutions/manufacturing", name: "解决方案详情-制造" },
{ path: "/services", name: "服务列表" },
{ path: "/services/software", name: "服务详情-软件开发" },
{ path: "/news", name: "新闻列表" },
{ path: "/contact", name: "联系我们" },
];
interface HeadingIssue {
type: 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'skipped_level';
type:
| "missing_h1"
| "multiple_h1"
| "empty_heading"
| "skipped_level"
| "http_error";
message: string;
detail?: string;
}
@@ -44,22 +49,48 @@ interface PageResult {
function startServer(): Promise<ReturnType<typeof spawn>> {
return new Promise((resolve, reject) => {
const server = spawn('npm', ['run', 'preview'], {
cwd: path.resolve(__dirname, '../..'),
stdio: 'pipe',
shell: true,
});
// N-24:`npm run preview` = `next start -p 3000`,而本项目是 output:'standalone',
// Next 16 明确告警 `"next start" does not work with "output: standalone"` ⇒ 此前该门禁跑在不受支持的降级路径上。
// 有产物就直起 standalone server(与 check:axe / Jenkinsfile / lighthouserc 同法),否则回退并大声告警。
const standalone = path.resolve(
__dirname,
"../../dist/standalone/server.js",
);
const useStandalone = require("fs").existsSync(standalone);
if (!useStandalone) {
console.warn(
"⚠ 未找到 dist/standalone/server.js,回退到 `npm run preview`(该路径在 standalone 下不受支持,结论仅参考)",
);
}
const server = spawn(
useStandalone ? "node" : "npm",
useStandalone ? [standalone] : ["run", "preview"],
{
env: useStandalone
? {
...process.env,
HOSTNAME: "localhost",
PORT: "3000",
NODE_ENV: "production",
}
: process.env,
cwd: path.resolve(__dirname, "../.."),
stdio: "pipe",
shell: true,
detached: true,
},
);
server.stdout?.on('data', (data: Buffer) => {
server.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
if (output.includes('Ready') || output.includes('3000')) {
if (output.includes("Ready") || output.includes("3000")) {
resolve(server);
}
});
server.stderr?.on('data', (data: Buffer) => {
server.stderr?.on("data", (data: Buffer) => {
const output = data.toString();
if (output.includes('EADDRINUSE') || output.includes('Failed')) {
if (output.includes("EADDRINUSE") || output.includes("Failed")) {
reject(new Error(output));
}
});
@@ -68,29 +99,50 @@ function startServer(): Promise<ReturnType<typeof spawn>> {
});
}
// 负 PID = 杀整个进程组。spawn 的 `npm run preview` 只是包装进程,真正 LISTEN :3000 的是它的
// next-server 孙进程;只 kill 包装进程会让孙进程被 init 收养继续占端口(验收报告 §9 实测残留 39 分钟),
// 叠加 Playwright 的 reuseExistingServer: true 后,下一轮 E2E / Lighthouse 会静默复用它。
let serverStopped = false;
function stopServer(server: ReturnType<typeof spawn> | null | undefined): void {
if (!server?.pid || serverStopped) return;
serverStopped = true;
try {
process.kill(-server.pid, "SIGTERM");
} catch {
/* 进程组已自行退出 */
}
}
async function waitForServer(url: string, retries = 30): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, { method: 'HEAD' });
if (res.ok || res.status === 404) return;
const res = await fetch(url, { method: "HEAD" });
// N-24:原本 `|| res.status === 404` 把「已响应但站点是错的」当成就绪;
// 而 :3000 上任何别的服务都会立刻满足它,于是整轮量的是错对象。
if (res.ok) return;
} catch {
await new Promise((r) => setTimeout(r, 1000));
// 服务未起:连接被拒
}
// 就绪等待必须每轮都退避:启动期返回 503/500 等非 ok 状态时,
// 若只在 catch 里 sleep,30 次重试会在毫秒内跑完并误报「服务未就绪」。
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Server at ${url} did not become ready in time`);
}
function checkHeadings(headings: Array<{ level: number; text: string }>): HeadingIssue[] {
function checkHeadings(
headings: Array<{ level: number; text: string }>,
): HeadingIssue[] {
const issues: HeadingIssue[] = [];
const h1Headings = headings.filter((h) => h.level === 1);
if (h1Headings.length === 0) {
issues.push({ type: 'missing_h1', message: '页面缺少 h1 标题' });
issues.push({ type: "missing_h1", message: "页面缺少 h1 标题" });
} else if (h1Headings.length > 1) {
issues.push({
type: 'multiple_h1',
type: "multiple_h1",
message: `页面存在 ${h1Headings.length} 个 h1 标题`,
detail: h1Headings.map((h) => h.text).join(' | '),
detail: h1Headings.map((h) => h.text).join(" | "),
});
}
@@ -98,7 +150,7 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
const current = headings[i]!;
if (!current.text.trim()) {
issues.push({
type: 'empty_heading',
type: "empty_heading",
message: `h${current.level} 标题内容为空`,
});
continue;
@@ -108,7 +160,7 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
const previous = headings[i - 1]!;
if (current.level > previous.level + 1) {
issues.push({
type: 'skipped_level',
type: "skipped_level",
message: `标题层级跳跃: h${previous.level} → h${current.level}`,
detail: `"${previous.text}" → "${current.text}"`,
});
@@ -121,13 +173,15 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
async function runCheck() {
let server;
try {
console.log('🚀 启动预览服务...');
console.log("🚀 启动预览服务...");
server = await startServer();
await waitForServer(BASE_URL);
console.log('🔍 检查页面标题层级...\n');
console.log("🔍 检查页面标题层级...\n");
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
});
const page = await context.newPage();
const results: PageResult[] = [];
@@ -136,14 +190,40 @@ async function runCheck() {
for (const { path: pagePath, name } of PAGES) {
const url = `${BASE_URL}${pagePath}`;
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
// N-24:page.goto 不对 404/500 抛错,而 Next 的 not-found 页只有一个 h1、无层级跳跃
// ⇒ 路由消失时本门禁会安静地判「层级正常」。必须显式断言状态码。
const httpStatus = response?.status() ?? 0;
if (httpStatus >= 400) {
totalIssues++;
results.push({
name,
url,
passed: false,
headings: [],
issues: [
{
type: "http_error",
message: `HTTP ${httpStatus} —— 页面未正常渲染(路由缺失/服务端错误)`,
detail: "标题层级检查未执行",
},
],
});
console.log(` ❌ ${name} (${pagePath}): HTTP ${httpStatus}`);
continue;
}
await page.waitForTimeout(1500);
const headings = await page.evaluate(() => {
const elements = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const elements = Array.from(
document.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
return elements.map((el) => ({
level: parseInt(el.tagName[1]!, 10),
text: el.textContent?.trim() ?? '',
text: el.textContent?.trim() ?? "",
}));
});
@@ -159,10 +239,12 @@ async function runCheck() {
};
results.push(result);
const status = issues.length === 0 ? '✅' : '❌';
const status = issues.length === 0 ? "✅" : "❌";
console.log(`${status} ${name} (${issues.length} 个问题)`);
for (const issue of issues) {
console.log(` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ''}`);
console.log(
` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ""}`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -172,31 +254,35 @@ async function runCheck() {
url,
passed: false,
headings: [],
issues: [{ type: 'missing_h1', message: `页面加载失败: ${message}` }],
issues: [{ type: "missing_h1", message: `页面加载失败: ${message}` }],
});
}
}
await browser.close();
console.log('\n📊 检查摘要');
console.log("\n📊 检查摘要");
console.log(` 扫描页面: ${results.length}`);
console.log(` 通过页面: ${results.filter((r) => r.passed).length}`);
console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`);
console.log(` 问题总数: ${totalIssues}`);
const reportPath = path.resolve(__dirname, '../../heading-hierarchy-report.json');
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
const reportPath = path.resolve(
__dirname,
"../../heading-hierarchy-report.json",
);
require("fs").writeFileSync(reportPath, JSON.stringify(results, null, 2));
console.log(`\n📝 详细报告已保存: ${reportPath}`);
// process.exit() 不会执行 finally,所以两处出口都得自己收服服务再退出。
stopServer(server);
process.exit(totalIssues > 0 ? 1 : 0);
} catch (error) {
console.error('检查执行失败:', error);
console.error("检查执行失败:", error);
stopServer(server);
process.exit(1);
} finally {
if (server) {
server.kill('SIGTERM');
}
stopServer(server);
}
}
+908
View File
@@ -0,0 +1,908 @@
#!/usr/bin/env tsx
/**
* 动效契约检查脚本(时长档位 + 缓动曲线)
*
* 背景:验收报告 `docs/acceptance/2026-09-23-gates/final-verdict.md` §3 的 **N-29** 记录:
* `check:a11y` 只等于 `check:contrast` + `check:headings` + `check:brand-token`,
* 「没有任何脚本约束动效时长与缓动曲线」。`CONTEXT.md`「动效设计四原则」是**有权威出处的硬约束**,
* 却长期只是文档 → 本脚本把它变成可判定的门禁。
*
* 唯一真源:`CONTEXT.md:73-85`(✅ 2026-06-29 确认)
* - `CONTEXT.md:76` 入场 **180–280ms**(原文明确点名 `--transition-fast` … `--transition-normal`)、
* hover **150ms**、反馈 **100ms**
* - `CONTEXT.md:77` 统一以 **`ease-ink` = cubic-bezier(0.22, 1, 0.36, 1)** 为默认缓动曲线
* - `CONTEXT.md:82` **禁止超过 700ms 的入场动效**(硬上限)
* (`CONTEXT.md:81` 禁止循环动画、`:83` 禁止 spring 做内容入场 **不在本脚本射程内** ——
* 「是否装饰性循环 / 是否内容入场」无法从字面机械判定,留给后续独立门禁,见下方「本门禁测不到的面」。)
*
* ── 四条规则(每条都可被触发,无 warn 分支)────────────────────────────────
* R1 `token-band` 令牌时长带:`--transition-instant` 必须 == 100ms(反馈档)、
* `--transition-fast` / `--transition-normal` 必须落在 180–280ms(入场档)。
* R2 `entrance-cap` 700ms 硬上限:CSS `transition*:` / `animation*:` 声明值、
* framer-motion `transition` 对象里的 `duration: <秒>`、
* Tailwind `duration-NNN` / `duration-[...]` 类、
* `tailwind.config.js` 的 `animation` 简写串,一律不得超过 700ms。
* 例外一:带 `infinite` 的循环动画不计入(循环不属「入场」,另立门禁)。
* 例外二:`*-delay` 声明判的是**延迟/错落**而非时长,属 `CONTEXT.md:78`
* 的 stagger 口径(子元素 30–60ms、Section 间 100–150ms),本脚本不判。
* `--transition-*` 家族里非 fast/normal/instant 的成员(slow / slower / gentle)
* 同样受此上限约束 —— `gentle: 1000ms` 就是这么被抓出来的。
* R3 `css-transition-token` CSS/内联 transition 声明的时长必须走 `var(--transition-*)` 令牌,
* 不得写裸 ms/s 字面量(令牌层是唯一事实来源)。
* 例外:含 `!important` 的 `prefers-reduced-motion` 硬开关(globals.css:767-775)。
* 引用了不存在的 `--transition-*` 令牌同样判红(悬空引用=死样式)。
* R4 `easing-palette` 写死的贝塞尔曲线必须等于令牌层声明的某条曲线。
* 允许集合**从 `--ease-*` 令牌声明里解析出来**(不在本脚本里硬编码),
* 并钉住契约基准:`--ease-ink` 必须存在且 == [0.22, 1, 0.36, 1]。
* 射程:CSS 里的 `cubic-bezier(...)`(`--ease-*` 声明自身除外)、
* framer `ease: [a,b,c,d]`、`const EASE* = [a,b,c,d]`、
* Tailwind `ease-[cubic-bezier(...)]` 任意值。
*
* ── 退出码纪律(N-22 / N-24 / A-6 的直接回应)──────────────────────────────
* 0 = 已扫描且无违规;1 = 存在违规;**2 = 根本没能度量**
* (目标目录/CSS 令牌文件缺失、扫描到的源码文件数为 0、`--ease-*` 或 `--transition-*` 令牌一个都没解析到)。
* 「空扫描」绝不等于「干净」:摘要行会打印扫描到的文件数与声明数,分母为 0 即判 2。
* 与 `scripts/accessibility/axe-node-count.mjs:52` 的 `fatal(msg, 2)` 同一口径。
*
* ── 本门禁测不到的面(明确交代,别把「绿」读成「动效全对」)────────────────
* 1. hover 150ms / 入场 180–280ms 的**分类判定**:一个裸时长字面量属于 hover、反馈还是入场,
* 无法从 class 串可靠推出 ⇒ R1 只在**被 CONTEXT.md:76 点名的三个令牌**上执行档位,
* R2 只对**全体**时长执行 700ms 上限。故 `duration-200` 的 hover 过渡、
* `tailwind.config.js` 里 `animate-fade-in-up: 0.7s` 这类「上限内但超入场档」的写法**不会判红**。
* 2. 具名缓动关键字(`ease-out` / `ease-in-out` / `linear`)与 framer 字符串别名(`ease: 'easeOut'`)
* 不做约束 —— CONTEXT.md 的令牌层本身还留着 5 条非 ink 曲线,一刀切会与令牌层冲突。
* 3. `duration={N}` 这类 JSX prop 不解析(同一 prop 名在 `ScrollReveal` 是秒、
* 在 `AnimatedCounter` 是毫秒,机械判定必假阳),只看对象字面量 `duration:` 且处于 transition 对象内。
* 4. `CONTEXT.md:81` 循环动画、`:83` spring 入场、`:78` stagger(子元素 30–60ms、
* Section 间 100–150ms)与一切 `*-delay` 延迟值都不在射程内 —— 本脚本只判**时长**。
*
* 用法:
* npx tsx scripts/utils/check-motion-constraints.ts
* npx tsx scripts/utils/check-motion-constraints.ts --root /tmp/fixture # 自证伪:对夹具跑
* npx tsx scripts/utils/check-motion-constraints.ts --src src --css src/app/globals.css
*/
import fs from 'fs';
import path from 'path';
/* ------------------------------------------------------------------ *
* 契约常量(出处见文件头;数值与 CONTEXT.md:76/77/82 逐条对齐)
* ------------------------------------------------------------------ */
export const MOTION_SPEC = {
/** CONTEXT.md:76 入场下界 */
entranceMinMs: 180,
/** CONTEXT.md:76 入场上界 */
entranceMaxMs: 280,
/** CONTEXT.md:76 反馈档(`--transition-instant`) */
feedbackMs: 100,
/** CONTEXT.md:82 入场硬上限(「禁止超过 700ms」,故 700ms 本身合法) */
entranceCapMs: 700,
/** CONTEXT.md:77 默认缓动曲线 ease-ink */
inkCurve: [0.22, 1, 0.36, 1] as readonly number[],
} as const;
export const EXIT_CLEAN = 0;
export const EXIT_VIOLATION = 1;
export const EXIT_UNMEASURABLE = 2;
export type RuleId =
| 'token-band'
| 'entrance-cap'
| 'css-transition-token'
| 'easing-palette';
export interface RuleSpec {
id: RuleId;
title: string;
/** 权威出处 */
source: string;
suggestion: string;
}
export const MOTION_RULES: readonly RuleSpec[] = [
{
id: 'token-band',
title: 'R1 令牌时长带:instant==100ms、fast/normal ∈ 180–280ms',
source: 'CONTEXT.md:76',
suggestion: '把令牌值改回 CONTEXT.md 档位(反馈 100ms;入场 180–280ms),或换用已合规的令牌',
},
{
id: 'entrance-cap',
title: 'R2 时长硬上限:任何动效时长不得超过 700ms(infinite 循环动画除外)',
source: 'CONTEXT.md:82',
suggestion: '降到 ≤ 700ms,入场动画应落到 var(--transition-fast)…var(--transition-normal)',
},
{
id: 'css-transition-token',
title: 'R3 令牌间接层:transition 声明的时长必须走 var(--transition-*)',
source: 'CONTEXT.md:76(令牌层是时长的唯一事实来源)',
suggestion: '裸 ms/s 字面量 → var(--transition-instant | --transition-fast | --transition-normal)',
},
{
id: 'easing-palette',
title: 'R4 缓动曲线:写死的 cubic-bezier 必须等于 --ease-* 令牌集,且 --ease-ink 必须是 [0.22,1,0.36,1]',
source: 'CONTEXT.md:77',
suggestion: '改用 var(--ease-*) / ease-* 类,或把曲线值改回令牌层已声明的那条',
},
];
export interface MotionViolation {
rule: RuleId;
file: string;
line: number;
token: string;
message: string;
}
export interface ScanTargets {
/** 仓库根(相对路径都相对它解析);夹具自证伪时指向临时目录 */
root: string;
srcDir: string;
cssFiles: string[];
configFiles: string[];
}
export interface ScanCounts {
sourceFiles: number;
cssFiles: number;
configFiles: number;
declarations: number;
transitionTokens: number;
easeTokens: number;
bezierLiterals: number;
durationLiterals: number;
}
export interface ScanResult {
exitCode: number;
violations: MotionViolation[];
counts: ScanCounts;
/** 允许集合:从 CSS 令牌层 `--ease-*` 解析出的贝塞尔曲线(人类可读键) */
allowedCurves: string[];
/** 无法度量的原因(非空即 exitCode = EXIT_UNMEASURABLE) */
blockers: string[];
}
/* ------------------------------------------------------------------ *
* 通用工具
* ------------------------------------------------------------------ */
/** 输出走 process.stdout:与其余 checker 的排版一致,但不新增 `no-console` 告警 */
function emit(line: string): void {
process.stdout.write(`${line}\n`);
}
function emitError(line: string): void {
process.stderr.write(`${line}\n`);
}
/** `180ms` / `.6s` / `0.01ms` → 毫秒;解析不出来返回 null */
export function parseDurationMs(raw: string): number | null {
const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(raw.trim());
const value = match?.[1];
const unit = match?.[2];
if (!value || !unit) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return unit === 's' ? parsed * 1000 : parsed;
}
/** 值里出现的裸时长字面量(`2s` / `0.6s` / `150ms`) */
const TIME_LITERAL = /\b\d+(?:\.\d+)?(?:ms|s)\b/g;
/** 贝塞尔曲线:`cubic-bezier(a, b, c, d)` / `[a, b, c, d]` 共用 */
export function normalizeCurve(numbers: readonly number[]): string | null {
if (numbers.length !== 4) return null;
if (!numbers.every((n) => Number.isFinite(n))) return null;
return numbers.map((n) => n.toFixed(3)).join(', ');
}
function curveOfBezierArgs(args: string): string | null {
const numbers = args
.split(',')
.map((part) => Number(part.trim()))
.filter((n) => Number.isFinite(n));
return normalizeCurve(numbers);
}
const CUBIC_BEZIER = /cubic-bezier\(\s*([^)]*)\)/gi;
/** 逐行文本的行号解析器(一次建表,二分查找) */
function makeLineResolver(text: string): (index: number) => number {
const newlines: number[] = [];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '\n') newlines.push(i);
}
return (index: number) => {
let low = 0;
let high = newlines.length;
while (low < high) {
const mid = (low + high) >> 1;
if ((newlines[mid] as number) < index) low = mid + 1;
else high = mid;
}
return low + 1;
};
}
export interface CssDeclaration {
/** `transition` / `transition-duration` / `animation` / `--transition-fast` / `--ease-ink` … */
prop: string;
value: string;
line: number;
isCustomProperty: boolean;
}
/**
* 从 CSS 文本里抽出「属性: 值」声明。
* `[^;{}]*` 天然跨行,故 globals.css:314 那种多行值也能拿到完整串。
* 自定义属性(`--transition-*` / `--ease-*` / `--card-transition`)与普通声明都会拿到,
* 由调用方按 prop 名分流;`--card-transition` 这类「组件级过渡令牌」也在射程内,
* 因为它同样是编译进产物的 CSS 值。
*/
export function extractCssDeclarations(text: string): CssDeclaration[] {
const declarations: CssDeclaration[] = [];
const lineOf = makeLineResolver(text);
const pattern = /(--[\w-]+|[a-z][\w-]*)\s*:\s*([^;{}]*)/gi;
let match: RegExpExecArray | null = pattern.exec(text);
while (match) {
const prop = match[1] as string;
const value = match[2] as string;
// 只看动效相关属性:transition* / animation* / --transition-* / --ease-* / --card-transition
const baseName = prop.replace(/^--/, '').toLowerCase();
const isMotionProperty =
/transition|animation/.test(baseName) || /^ease(-|$)/.test(baseName);
if (isMotionProperty) {
declarations.push({
prop,
value,
line: lineOf(match.index),
isCustomProperty: prop.startsWith('--'),
});
}
match = pattern.exec(text);
}
return declarations;
}
/** 收集 cubic-bezier 字面量(曲线键 + 原文 + 行号,行号相对传入的文本) */
function collectBezierLiterals(
text: string,
): Array<{ curve: string; token: string; line: number; index: number }> {
const lineOf = makeLineResolver(text);
const found: Array<{ curve: string; token: string; line: number; index: number }> = [];
CUBIC_BEZIER.lastIndex = 0;
let match: RegExpExecArray | null = CUBIC_BEZIER.exec(text);
while (match) {
const index = match.index;
const curve = curveOfBezierArgs(match[1] as string);
if (curve) {
found.push({ curve, token: match[0], line: lineOf(index), index });
}
match = CUBIC_BEZIER.exec(text);
}
return found;
}
/** 收集 `ease: [a,b,c,d]` / `const EASE = [a,b,c,d]` 形态的贝塞尔数组 */
function collectEaseArrays(text: string): Array<{ curve: string; token: string; line: number }> {
const lineOf = makeLineResolver(text);
const found: Array<{ curve: string; token: string; line: number }> = [];
const record = (token: string, args: string, index: number): void => {
const curve = curveOfBezierArgs(args);
if (curve) found.push({ curve, token: token.trim(), line: lineOf(index) });
};
// framer-motion `transition={{ ease: [0.22, 1, 0.36, 1] }}`
const easeKey = /\bease\s*:\s*\[([^\]]*)\]/gi;
let match: RegExpExecArray | null = easeKey.exec(text);
while (match) {
record(match[0], match[1] as string, match.index);
match = easeKey.exec(text);
}
// 模块级曲线常量 `const EASE = [...] as const` / `export const EASE_OUT = [...]`。
// 变量名单独用 /ease|curve/i 判定:`EASE` 全大写无法被大小写敏感的字面匹配抓住(首轮实测漏检过)。
const easeConst = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*\[([^\]]*)\]/g;
match = easeConst.exec(text);
while (match) {
if (/ease|curve/i.test(match[1] as string)) record(match[0], match[2] as string, match.index);
match = easeConst.exec(text);
}
return found;
}
/**
* 判定源码文本中「哪些行处于 framer-motion transition 对象内部」。
* 逐字符维护花括号深度:遇到 `transition`(`transition:` / `transition={{`)后的第一个 `{`
* 记为该对象的目标深度,深度回落到目标值以下即出栈。
* 这样能区分 `transition: { duration: 0.28 }`(动效)与 `toast(x, { duration: 3000 })`(毫秒显示时长)。
*/
export function markTransitionLines(lines: readonly string[]): boolean[] {
const flags: boolean[] = lines.map(() => false);
let depth = 0;
const stack: number[] = [];
lines.forEach((line, index) => {
const keyword = /\btransition\w*\s*[:=]/.exec(line);
let openedAt: number | null = null;
if (keyword) flags[index] = true;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
if (char === '{') {
depth += 1;
if (openedAt === null && keyword && i > keyword.index) openedAt = depth;
} else if (char === '}') {
if (stack.length > 0 && depth === (stack[stack.length - 1] as number)) stack.pop();
depth -= 1;
}
}
if (openedAt !== null) stack.push(openedAt);
if (stack.length > 0) flags[index] = true;
});
return flags;
}
/* ------------------------------------------------------------------ *
* 扫描器
* ------------------------------------------------------------------ */
interface TokenLayer {
/** `--transition-<name>` → 毫秒 */
transitionTokens: Map<string, number>;
/** 允许集合:`--ease-*` 令牌解析出的曲线键 */
allowedCurves: Set<string>;
/** `--ease-<name>` → 曲线键 */
easeTokens: Map<string, string>;
}
const TRANSITION_CUSTOM_PROP = /^--transition-([\w-]+)$/i;
const EASE_CUSTOM_PROP = /^--ease-([\w-]+)$/i;
/** 第一遍:从 CSS 令牌层读出 `--transition-*` 时长与 `--ease-*` 曲线(允许集合不硬编码,见 R4 说明) */
function readTokenLayer(cssDeclarations: CssDeclaration[], counts: ScanCounts): TokenLayer {
const transitionTokens = new Map<string, number>();
const easeTokens = new Map<string, string>();
const allowedCurves = new Set<string>();
for (const declaration of cssDeclarations) {
if (!declaration.isCustomProperty) continue;
const transitionName = declaration.prop.match(TRANSITION_CUSTOM_PROP)?.[1];
if (transitionName) {
const ms = parseDurationMs(declaration.value);
if (ms !== null) {
transitionTokens.set(transitionName.toLowerCase(), ms);
counts.transitionTokens += 1;
}
continue;
}
const easeName = declaration.prop.match(EASE_CUSTOM_PROP)?.[1];
if (easeName) {
counts.easeTokens += 1;
const curve = firstBezierCurve(declaration.value);
if (curve) {
easeTokens.set(easeName.toLowerCase(), curve);
allowedCurves.add(curve);
}
}
}
return { transitionTokens, easeTokens, allowedCurves };
}
/** 取串里第一条 cubic-bezier 的归一化曲线键(令牌定义用,单值语义) */
function firstBezierCurve(value: string): string | null {
const literal = collectBezierLiterals(value)[0];
return literal ? literal.curve : null;
}
/** CSS 令牌文件:R1 / R2 / R3 / R4 */
export function scanCssText(
text: string,
relativeFile: string,
layer: TokenLayer,
counts: ScanCounts,
): MotionViolation[] {
const violations: MotionViolation[] = [];
const declarations = extractCssDeclarations(text);
const push = (
rule: RuleId,
line: number,
token: string,
message: string,
): void => {
violations.push({ rule, file: relativeFile, line, token, message });
};
for (const declaration of declarations) {
counts.declarations += 1;
const transitionTokenName = declaration.prop.match(TRANSITION_CUSTOM_PROP)?.[1];
const easeTokenName = declaration.prop.match(EASE_CUSTOM_PROP)?.[1];
/* --- 令牌声明自身:R1 档位 + R2 上限 --- */
if (transitionTokenName) {
const ms = parseDurationMs(declaration.value);
if (ms === null) continue; // 非时长形态(注释/别名)由 readTokenLayer 已忽略
counts.durationLiterals += 1;
const name = transitionTokenName.toLowerCase();
if (name === 'instant' && ms !== MOTION_SPEC.feedbackMs) {
push(
'token-band',
declaration.line,
declaration.value.trim(),
`--transition-${name} 是反馈档,必须为 ${MOTION_SPEC.feedbackMs}ms(实测 ${ms}ms)`,
);
}
if ((name === 'fast' || name === 'normal') &&
(ms < MOTION_SPEC.entranceMinMs || ms > MOTION_SPEC.entranceMaxMs)) {
push(
'token-band',
declaration.line,
declaration.value.trim(),
`--transition-${name} 是入场档,必须落在 ${MOTION_SPEC.entranceMinMs}–${MOTION_SPEC.entranceMaxMs}ms(实测 ${ms}ms)`,
);
}
if (ms > MOTION_SPEC.entranceCapMs) {
push(
'entrance-cap',
declaration.line,
declaration.value.trim(),
`--transition-${name} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 入场硬上限`,
);
}
continue;
}
/* --- ease-* 令牌定义自身:R4 的 --ease-ink 基准钉(曲线本身不进「写死曲线」判定) --- */
if (easeTokenName) {
if (easeTokenName.toLowerCase() === 'ink') {
const curve = firstBezierCurve(declaration.value);
const expected = normalizeCurve(MOTION_SPEC.inkCurve);
if (curve !== expected) {
push(
'easing-palette',
declaration.line,
declaration.value.trim(),
`--ease-ink 必须等于 cubic-bezier(${MOTION_SPEC.inkCurve.join(', ')})(实测 ${curve ?? '无法解析'})`,
);
}
}
continue;
}
// `--card-transition` 这类组件级过渡令牌同样算 transition 声明(它会被原样编译进 CSS 值)
const isTransition = /transition/i.test(declaration.prop);
const isAnimation = /animation/i.test(declaration.prop);
if (!isTransition && !isAnimation) continue;
// `*-delay` 是**延迟/错落**,不是时长:CONTEXT.md:78 的 stagger(子元素 30–60ms、Section 间 100–150ms)
// 是另一条口径,本门禁不判(globals.css:1285-1291 的 .animation-delay-200…800 工具类即属此类)。
if (/-delay$/i.test(declaration.prop.replace(/^--/, ''))) continue;
const value = declaration.value.trim();
if (!value) continue;
/* --- R4:CSS 里写死的 cubic-bezier 必须在令牌允许集合里 --- */
for (const literal of collectBezierLiterals(value)) {
counts.bezierLiterals += 1;
if (!layer.allowedCurves.has(literal.curve)) {
push(
'easing-palette',
declaration.line,
`cubic-bezier(${literal.curve})`,
`${declaration.prop} 用了未在 --ease-* 中声明的曲线`,
);
}
}
/* --- R3:transition 声明的时长必须走令牌 --- */
const reducedMotionKillSwitch = isTransition && /!important/i.test(value);
if (isTransition && !reducedMotionKillSwitch) {
for (const raw of value.match(TIME_LITERAL) ?? []) {
counts.durationLiterals += 1;
push(
'css-transition-token',
declaration.line,
raw,
`${declaration.prop} 写了裸时长字面量,应改用 var(--transition-*)`,
);
}
}
/* --- R2:700ms 硬上限(infinite 循环动画豁免) --- */
const loopsInfinitely = /\binfinite\b/i.test(value);
const rawDurations = value.match(TIME_LITERAL) ?? [];
if (!loopsInfinitely) {
for (const raw of rawDurations) {
const ms = parseDurationMs(raw);
if (ms === null) continue;
if (ms > MOTION_SPEC.entranceCapMs) {
push(
'entrance-cap',
declaration.line,
raw,
`${declaration.prop} 的 ${raw} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
);
}
}
// var(--transition-x) 引用:解析后同样受上限约束;悬空引用即死样式
for (const reference of value.match(/var\(\s*--transition-[\w-]+\s*\)/g) ?? []) {
const name = /--transition-([\w-]+)/.exec(reference)?.[1]?.toLowerCase();
if (!name) continue;
const ms = layer.transitionTokens.get(name);
if (ms === undefined) {
push(
'css-transition-token',
declaration.line,
`var(--transition-${name})`,
`引用了未定义的 --transition-${name} 令牌(编译后不生效)`,
);
continue;
}
counts.durationLiterals += 1;
if (ms > MOTION_SPEC.entranceCapMs) {
push(
'entrance-cap',
declaration.line,
`var(--transition-${name})`,
`${declaration.prop} 解析为 ${ms}ms,超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
);
}
}
}
}
return violations;
}
const TAILWIND_DURATION_CLASS = /\bduration-\[?(\d+(?:\.\d+)?(?:ms|s)?)\]?/g;
const TAILWIND_EASE_ARBITRARY = /\bease-\[([^\]]*)\]/g;
/** Tailwind 时长类:`duration-150` 的裸数字单位是 ms,`duration-[0.6s]` 才带单位 */
export function parseTailwindDuration(raw: string): number | null {
const withUnit = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(raw);
if (withUnit) return parseDurationMs(withUnit[0]);
const bare = /^\d+(?:\.\d+)?$/.exec(raw);
if (!bare) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
/** 源码文件(.ts / .tsx):R2(framer duration、Tailwind duration-*)+ R4(ease 数组 / 任意值)+ R3(内联 transition 串) */
export function scanSourceText(
text: string,
relativeFile: string,
layer: TokenLayer,
counts: ScanCounts,
): MotionViolation[] {
const violations: MotionViolation[] = [];
const lines = text.split(/\r?\n/);
const inTransition = markTransitionLines(lines);
const push = (rule: RuleId, line: number, token: string, message: string): void => {
violations.push({ rule, file: relativeFile, line, token, message });
};
lines.forEach((line, index) => {
const lineNumber = index + 1;
/* R2 —— framer-motion transition 对象里的 `duration: <秒>` */
if (inTransition[index]) {
const durationMatch = /(?<![\w-])duration\s*:\s*(\d+(?:\.\d+)?)(?![\w.])/.exec(line);
const seconds = durationMatch?.[1];
if (seconds) {
const ms = Number(seconds) * 1000;
counts.durationLiterals += 1;
if (ms > MOTION_SPEC.entranceCapMs) {
push(
'entrance-cap',
lineNumber,
`duration: ${seconds}`,
`framer-motion transition 的 duration ${seconds}s = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
);
}
}
}
/* R3 —— 内联/常量里的 CSS transition 串(`transition: 'all 0.3s cubic-bezier(...)'`) */
const inlineTransition = /\btransition\s*:\s*['"`]([^'"`]*)['"`]/.exec(line);
const inlineValue = inlineTransition?.[1];
if (inlineValue) {
for (const raw of inlineValue.match(TIME_LITERAL) ?? []) {
counts.durationLiterals += 1;
push(
'css-transition-token',
lineNumber,
raw,
'内联 transition 串写了裸时长字面量,CSS 变量在 style 串里同样可用:var(--transition-*)',
);
const ms = parseDurationMs(raw);
if (ms !== null && ms > MOTION_SPEC.entranceCapMs) {
push('entrance-cap', lineNumber, raw, `${raw} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`);
}
}
for (const literal of collectBezierLiterals(inlineValue)) {
counts.bezierLiterals += 1;
if (!layer.allowedCurves.has(literal.curve)) {
push('easing-palette', lineNumber, `cubic-bezier(${literal.curve})`, '内联 transition 串的曲线未在 --ease-* 中声明');
}
}
}
/* R2 —— Tailwind duration-NNN / duration-[...] 类 */
TAILWIND_DURATION_CLASS.lastIndex = 0;
let classMatch: RegExpExecArray | null = TAILWIND_DURATION_CLASS.exec(line);
while (classMatch) {
const raw = classMatch[1] as string;
const ms = parseTailwindDuration(raw);
counts.durationLiterals += 1;
if (ms !== null && ms > MOTION_SPEC.entranceCapMs) {
push('entrance-cap', lineNumber, `duration-${raw}`, `Tailwind 时长类 = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`);
}
classMatch = TAILWIND_DURATION_CLASS.exec(line);
}
/* R4 —— Tailwind ease-[cubic-bezier(...)] 任意值 */
TAILWIND_EASE_ARBITRARY.lastIndex = 0;
let easeClass: RegExpExecArray | null = TAILWIND_EASE_ARBITRARY.exec(line);
while (easeClass) {
const inner = easeClass[1] as string;
for (const literal of collectBezierLiterals(inner)) {
counts.bezierLiterals += 1;
if (!layer.allowedCurves.has(literal.curve)) {
push('easing-palette', lineNumber, `ease-[cubic-bezier(${literal.curve})]`, 'Tailwind 任意值曲线未在 --ease-* 中声明');
}
}
easeClass = TAILWIND_EASE_ARBITRARY.exec(line);
}
});
/* R4 —— `ease: [a,b,c,d]` 与 `const EASE* = [a,b,c,d]`(跨行也能抓到,故整文件扫) */
for (const item of collectEaseArrays(text)) {
counts.bezierLiterals += 1;
if (!layer.allowedCurves.has(item.curve)) {
push(
'easing-palette',
item.line,
item.token,
`曲线 [${item.curve}] 不在令牌层 --ease-* 允许集合内(默认应为 ease-ink [${normalizeCurve(MOTION_SPEC.inkCurve)}])`,
);
}
}
return violations;
}
/** tailwind.config.js 的 `animation` 简写串(animate-* 类的真实时长来源):R2 + R4 */
export function scanConfigText(
text: string,
relativeFile: string,
layer: TokenLayer,
counts: ScanCounts,
): MotionViolation[] {
const violations: MotionViolation[] = [];
const lineOf = makeLineResolver(text);
const stringPair = /['"]([^'"]+)['"]\s*:\s*['"]([^'"]*)['"]/g;
let match: RegExpExecArray | null = stringPair.exec(text);
while (match) {
const value = match[2] as string;
const line = lineOf(match.index);
const loopsInfinitely = /\binfinite\b/i.test(value);
for (const raw of value.match(TIME_LITERAL) ?? []) {
counts.durationLiterals += 1;
const ms = parseDurationMs(raw);
if (!loopsInfinitely && ms !== null && ms > MOTION_SPEC.entranceCapMs) {
violations.push({
rule: 'entrance-cap',
file: relativeFile,
line,
token: raw,
message: `animate-${match[1]} 的 ${raw} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
});
}
}
for (const literal of collectBezierLiterals(value)) {
counts.bezierLiterals += 1;
if (!layer.allowedCurves.has(literal.curve)) {
violations.push({
rule: 'easing-palette',
file: relativeFile,
line,
token: `cubic-bezier(${literal.curve})`,
message: `animate-${match[1]} 的曲线未在 --ease-* 中声明`,
});
}
}
match = stringPair.exec(text);
}
return violations;
}
const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
function isTestPath(relativeFile: string): boolean {
return (
/\.test\.[jt]sx?$/.test(relativeFile) ||
relativeFile.includes('__tests__') ||
relativeFile.includes(`${path.sep}_archive`) ||
relativeFile.includes('/_archive/')
);
}
function collectSourceFiles(absoluteDir: string, accumulator: string[]): void {
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
const entryPath = path.join(absoluteDir, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(entryPath, accumulator);
} else if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
accumulator.push(entryPath);
}
}
}
function relative(root: string, target: string): string {
return path.relative(root, target) || '.';
}
/* ------------------------------------------------------------------ *
* 编排
* ------------------------------------------------------------------ */
export function runScan(targets: ScanTargets): ScanResult {
const counts: ScanCounts = {
sourceFiles: 0,
cssFiles: 0,
configFiles: 0,
declarations: 0,
transitionTokens: 0,
easeTokens: 0,
bezierLiterals: 0,
durationLiterals: 0,
};
const blockers: string[] = [];
const violations: MotionViolation[] = [];
const cssTexts: Array<{ file: string; text: string }> = [];
for (const cssFile of targets.cssFiles) {
const absolute = path.resolve(targets.root, cssFile);
if (!fs.existsSync(absolute)) {
blockers.push(`CSS 令牌文件不存在:${relative(targets.root, absolute)}`);
continue;
}
cssTexts.push({ file: relative(targets.root, absolute), text: fs.readFileSync(absolute, 'utf8') });
counts.cssFiles += 1;
}
// 允许集合来自令牌层,故必须先跑一遍
const allCssDeclarations = cssTexts.flatMap((item) => extractCssDeclarations(item.text));
const layer = readTokenLayer(allCssDeclarations, counts);
if (counts.easeTokens === 0) {
blockers.push('未从 CSS 令牌层解析到任何 --ease-* 声明(允许集合为空,R4 无法判定)');
}
if (counts.transitionTokens === 0) {
blockers.push('未从 CSS 令牌层解析到任何 --transition-* 时长(R1/R2 无法判定)');
}
for (const item of cssTexts) {
violations.push(...scanCssText(item.text, item.file, layer, counts));
}
const absoluteSrc = path.resolve(targets.root, targets.srcDir);
if (!fs.existsSync(absoluteSrc)) {
blockers.push(`扫描目录不存在:${relative(targets.root, absoluteSrc)}`);
} else {
const files: string[] = [];
collectSourceFiles(absoluteSrc, files);
const candidates = files
.map((file) => relative(targets.root, file))
.filter((file) => !isTestPath(file))
.sort();
for (const file of candidates) {
const text = fs.readFileSync(path.resolve(targets.root, file), 'utf8');
counts.sourceFiles += 1;
violations.push(...scanSourceText(text, file, layer, counts));
}
if (candidates.length === 0) {
blockers.push(`扫描目录 ${relative(targets.root, absoluteSrc)} 下没有可判定的 .ts/.tsx 源码文件`);
}
}
for (const configFile of targets.configFiles) {
const absolute = path.resolve(targets.root, configFile);
if (!fs.existsSync(absolute)) {
blockers.push(`动效配置文件不存在:${relative(targets.root, absolute)}`);
continue;
}
counts.configFiles += 1;
violations.push(...scanConfigText(fs.readFileSync(absolute, 'utf8'), relative(targets.root, absolute), layer, counts));
}
// 排序按「文件 → 行号(数值)→ 规则」,别让 `:10` 排在 `:8` 前面
violations.sort((a, b) =>
a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule),
);
const exitCode = blockers.length > 0 ? EXIT_UNMEASURABLE : violations.length > 0 ? EXIT_VIOLATION : EXIT_CLEAN;
return {
exitCode,
violations,
counts,
allowedCurves: [...layer.allowedCurves].sort(),
blockers,
};
}
function parseArgs(argv: string[]): ScanTargets {
const readFlag = (flag: string): string | undefined => {
const index = argv.indexOf(flag);
return index !== -1 ? argv[index + 1] : undefined;
};
const repoRoot = path.resolve(__dirname, '../..');
const root = path.resolve(repoRoot, readFlag('--root') ?? '.');
const css = readFlag('--css');
const config = readFlag('--config');
return {
root,
srcDir: readFlag('--src') ?? 'src',
cssFiles: css ? css.split(',').map((item) => item.trim()).filter(Boolean) : ['src/app/globals.css'],
configFiles: config ? config.split(',').map((item) => item.trim()).filter(Boolean) : ['tailwind.config.js'],
};
}
function main(): void {
const targets = parseArgs(process.argv.slice(2));
emit('\n🎬 动效契约检查(时长档位 + 缓动曲线)\n');
emit(` 契约:入场 ${MOTION_SPEC.entranceMinMs}–${MOTION_SPEC.entranceMaxMs}ms · 反馈 ${MOTION_SPEC.feedbackMs}ms · 上限 ${MOTION_SPEC.entranceCapMs}ms · ease-ink [${MOTION_SPEC.inkCurve.join(', ')}]`);
emit(' 出处:CONTEXT.md:76 / :77 / :82(N-29:此前无任何脚本约束)\n');
const result = runScan(targets);
emit(` 令牌层允许曲线:${result.allowedCurves.length ? result.allowedCurves.map((c) => `[${c}]`).join(' ') : '(无)'}`);
for (const violation of result.violations) {
emit(`❌ [${violation.rule}] ${violation.file}:${violation.line} ${violation.token}`);
emit(` ${violation.message}`);
}
emit('\n📊 检查摘要');
emit(` 扫描源码文件: ${result.counts.sourceFiles}(.ts/.tsx,已排除测试与 _archive)`);
emit(` 扫描令牌/配置: CSS ${result.counts.cssFiles} · config ${result.counts.configFiles}`);
emit(` 判定分母: CSS 声明 ${result.counts.declarations} · 时长字面量 ${result.counts.durationLiterals} · 贝塞尔字面量 ${result.counts.bezierLiterals} · --transition-* 令牌 ${result.counts.transitionTokens} · --ease-* 令牌 ${result.counts.easeTokens}`);
emit(` 违规处数: ${result.violations.length}`);
if (result.violations.length > 0) {
const byRule = new Map<RuleId, number>();
for (const violation of result.violations) {
byRule.set(violation.rule, (byRule.get(violation.rule) ?? 0) + 1);
}
emit('\n📋 分规则计数');
for (const rule of MOTION_RULES) {
emit(` ${rule.id}: ${byRule.get(rule.id) ?? 0} —— ${rule.title}(${rule.source})`);
}
const firstRule = MOTION_RULES.find((rule) => rule.id === result.violations[0]?.rule);
if (firstRule) emit(`\n 修复方向示例(${firstRule.id}):${firstRule.suggestion}`);
}
if (result.blockers.length > 0) {
emitError('\n🚫 本轮**未能度量**(空扫描 ≠ 干净,判 2):');
for (const blocker of result.blockers) emitError(` • ${blocker}`);
process.exit(EXIT_UNMEASURABLE);
}
if (result.violations.length > 0) {
emitError('\n❌ 存在动效契约违反!');
process.exit(EXIT_VIOLATION);
}
emit('\n✅ 动效时长与缓动曲线全部落在 CONTEXT.md 契约内!');
process.exit(EXIT_CLEAN);
}
if (require.main === module) {
main();
}
+31 -11
View File
@@ -58,13 +58,24 @@ function parseCookies(setCookieHeader: string | null): Array<{ name: string; fla
}
function checkCookies(cookies: Array<{ name: string; flags: string[] }>): CookieCheck[] {
return cookies.map((cookie) => ({
name: cookie.name,
hasHttpOnly: cookie.flags.some((f) => f === 'httponly'),
hasSecure: cookie.flags.some((f) => f === 'secure'),
hasSameSite: cookie.flags.some((f) => f.startsWith('samesite')),
status: 'pass' as const,
}));
// N-22:此前 status 恒为字面量 'pass',上面三个 flag 全部白算、cookieFailed 永远为 0,
// 而汇总的 hasFailures 只看 headerChecks ⇒ 任何 cookie 属性错误都无法让本门禁变红。
return cookies.map((cookie) => {
const hasHttpOnly = cookie.flags.some((f) => f === 'httponly');
const hasSecure = cookie.flags.some((f) => f === 'secure');
const hasSameSite = cookie.flags.some((f) => f.startsWith('samesite'));
const missing = [!hasHttpOnly, !hasSameSite, !hasSecure];
const isSessionLike = /token|session|auth|jwt/i.test(cookie.name);
let status: CookieCheck['status'];
if (isSessionLike && (!hasHttpOnly || !hasSameSite)) {
status = 'fail';
} else if (missing.some(Boolean)) {
status = 'warn';
} else {
status = 'pass';
}
return { name: cookie.name, hasHttpOnly, hasSecure, hasSameSite, status };
});
}
async function checkSecurityHeaders(url: string): Promise<{
@@ -85,7 +96,9 @@ async function checkSecurityHeaders(url: string): Promise<{
header: 'Content-Security-Policy',
expected: '存在(需包含合理策略)',
actual: headers.get('content-security-policy'),
status: headers.get('content-security-policy') ? 'pass' : 'warn',
// N-22:缺 CSP 原本只 warn ⇒ 删掉整条 CSP 也无法让本门禁变红,而同族的
// X-Content-Type-Options 同样缺失却判 fail。站点确实发 CSP(E2E 已在产物上断言),故判红不会误伤。
status: headers.get('content-security-policy') ? ('pass' as const) : ('fail' as const),
description: '防止 XSS 和数据注入攻击的核心策略',
},
{
@@ -110,7 +123,13 @@ async function checkSecurityHeaders(url: string): Promise<{
header: 'Strict-Transport-Security',
expected: '存在(需包含 max-age)',
actual: headers.get('strict-transport-security'),
status: headers.get('strict-transport-security') ? 'pass' : 'fail',
// N-22:HSTS 由边缘(Nginx/CDN)注入,本地 standalone 产物上没有。无条件判 fail 会让本门禁
// 永远无法在分支产物上通过(只能打线上),故仅在 https 目标上缺失才判 fail。
status: headers.get('strict-transport-security')
? ('pass' as const)
: url.startsWith('https:')
? ('fail' as const)
: ('warn' as const),
description: '强制 HTTPS 连接,防止 SSL Strip 攻击',
},
{
@@ -134,7 +153,7 @@ async function checkSecurityHeaders(url: string): Promise<{
status: (() => {
const val = headers.get('x-xss-protection');
if (val === '0' || val === '1; mode=block') return 'pass' as const;
return val ? 'warn' as const : 'warn' as const;
return val ? ('warn' as const) : ('pass' as const);
})(),
description: '已废弃的 XSS 过滤器(现代浏览器不再需要)',
},
@@ -281,7 +300,8 @@ async function main() {
printSummary(headerChecks, cookieChecks);
// Determine exit code
const hasFailures = headerChecks.some((c) => c.status === 'fail');
const hasFailures =
headerChecks.some((c) => c.status === 'fail') || cookieChecks.some((c) => c.status === 'fail');
if (hasFailures) {
console.log('\n❌ 存在失败的安全头检查项!');
process.exit(1);