#!/usr/bin/env node /** * verify-versions.mjs — 文档站版本切换的静态门禁(只读,不启动服务)。 * * 覆盖三件事: * 1) 清单契约:site/versions.json 的结构、顺序(最新版在首位)、path 形态; * 2) 入口契约:index.html 的版本控件标记 + app.js 的加载/渲染/绑定实现; * 3) 快照契约:磁盘上每个 site// 目录都必须是「能独立打开」的站点快照。 * * 运行时行为(点一下真的跳过去)由 tools/verify-site-routes.mjs 用真浏览器覆盖。 */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; const ROOT = join(import.meta.dirname, '..'); const SITE = join(ROOT, 'site'); const failures = []; const checks = []; function check(name, ok, detail = '') { checks.push({ name, ok, detail }); if (!ok) failures.push(`${name}${detail ? `: ${detail}` : ''}`); } const app = readFileSync(join(SITE, 'app.js'), 'utf8'); const index = readFileSync(join(SITE, 'index.html'), 'utf8'); const devServer = readFileSync(join(SITE, 'dev-server.js'), 'utf8'); const nginx = readFileSync(join(ROOT, 'nginx.conf'), 'utf8'); const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const manifestPath = join(SITE, 'versions.json'); /* ---------- 1. 清单契约 ---------- */ check('versions.json exists', existsSync(manifestPath), 'run npm run precompute'); let manifest = null; if (existsSync(manifestPath)) { try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch (e) { check('versions.json parses', false, String(e.message).slice(0, 120)); } } if (manifest) { const list = manifest.versions; check('versions.json has entries', Array.isArray(list) && list.length > 0); check('siteDir is site', manifest.siteDir === 'site', `站点根靠 pathname 里第一段 "/site/" 反推,目录名改了会全站 404(实际 ${manifest.siteDir})`); check('latest flag points at the first entry', !!(list && list[0] && list[0].latest === true)); check('latest field matches the first entry', !!(list && list[0] && manifest.latest === list[0].version)); const badVersion = (list || []).filter((v) => !/^\d+\.\d+\.\d+$/.test(String(v.version || ''))); check('every version is x.y.z', badVersion.length === 0, badVersion.map((v) => v.version).join(', ')); const badPath = (list || []).filter((v) => { const p = String(v.path || ''); return p !== '..' && !/^\d+\.\d+\.\d+$/.test(p); }); check('every path is ".." or a snapshot dir', badPath.length === 0, badPath.map((v) => v.path).join(', ')); check('package.json version is in the manifest', (list || []).some((v) => v.version === pkg.version), `package.json=${pkg.version} vs ${(list || []).map((v) => v.version).join('/')}`); /* 归档目录必须真在磁盘上(仓库根 //site/)—— 清单里出现幽灵版本就是「点进去 404」 */ const ghosts = (list || []).filter((v) => v.path !== '..' && !existsSync(join(ROOT, v.path, 'site', 'index.html'))); check('no ghost versions in the manifest', ghosts.length === 0, ghosts.map((v) => v.version).join(', ')); /* 反向:磁盘上有快照却没入清单(precompute 未重跑) */ const onDisk = readdirSync(ROOT, { withFileTypes: true }) .filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)) .filter((e) => existsSync(join(ROOT, e.name, 'site', 'index.html'))) .map((e) => e.name); const listed = new Set((list || []).map((v) => String(v.path))); const unlisted = onDisk.filter((v) => !listed.has(v)); check('every snapshot on disk is listed', unlisted.length === 0, unlisted.length ? `${unlisted.join(', ')}(重跑 npm run precompute)` : ''); /* 只有当前版本时的形态:单版本必须能干净降级 —— 角标退回纯展示、菜单与窄屏 select 都不出现。 (这里不禁止磁盘上存在快照:以后归档历史版本是正常操作,只要求清单与磁盘一致。) */ const single = list && list.length === 1; check('single-version manifest is the root site', !single || (list[0].path === '..' && list[0].latest === true), single ? JSON.stringify(list[0]) : `${list ? list.length : 0} versions`); } /* ---------- 2. 入口契约 ---------- */ check('version trigger markup', /id="version-trigger"/.test(index) && /aria-haspopup="listbox"/.test(index)); check('version chip is inside the trigger', /id="version-trigger"[\s\S]{0,400}?id="ver"/.test(index)); check('version menu markup', /id="version-menu"[\s\S]{0,200}?role="listbox"/.test(index)); /* 下拉面板是绝对定位:必须与触发角标同处一个定位容器(.ver-picker:position relative), 否则面板会锚到页面而不是角标下方 —— 实测跑到了左上角。 */ check('version menu has a positioned anchor', /class="ver-picker"[\s\S]{0,300}?id="version-trigger"[\s\S]{0,600}?id="version-menu"/.test(index) && /\.ver-picker\s*\{\s*position:\s*relative/.test(readFileSync(join(SITE, 'style.css'), 'utf8'))); check('version menu anchors below the trigger', /\.ver-menu\s*\{[\s\S]{0,300}?top:\s*calc\(100% \+ 8px\)/.test(readFileSync(join(SITE, 'style.css'), 'utf8'))); check('version menu opts use the dedicated classes', /ver-opt-num/.test(app) && /ver-opt-tag/.test(app) && /ver-opt-check/.test(app)); check('version menu starts hidden', /id="version-menu"[^>]*class="[^"]*hidden/.test(index)); check('narrow-screen version select', /id="ver-select-wrap"/.test(index) && /id="ver-select"/.test(index)); check('narrow-screen select starts hidden', /id="ver-select-wrap"[^>]*hidden/.test(index)); /* ≤560px 顶栏放不下版本控件(实测溢出),抽屉是这一档的入口:必须有标记、且默认不显示 */ check('drawer version select markup', /id="drawer-ver"/.test(index) && /id="drawer-ver-select"/.test(index)); check('drawer version select starts hidden', /id="drawer-ver"[^>]*hidden/.test(index)); check('drawer version select shares the option list', /\[select, \$\(['"]drawer-ver-select['"]\)\]/.test(app)); check('manifest loader exists', /function\s+loadVersionManifest\s*\(/.test(app) && /versionsManifestUrl/.test(app)); check('manifest fetched with no-store', /cache:\s*'no-store'/.test(app)); check('menu renderer exists', /function\s+renderVersionMenu\s*\(/.test(app)); check('interaction binding exists', /function\s+bindVersionSelect\s*\(/.test(app) && /bindVersionSelect\(\);/.test(app)); check('version switch preserves the route', /function\s+goVersion\s*\([\s\S]{0,400}?routePath\(\)/.test(app)); /* 路径校验按「行为」验,而不是按写法验。2026-09-20 实测:实现改写成 「sentinel + 严格 semver 白名单」后,原先匹配某种写法的检查误报失败, 而实际行为更强(只放行 '..' 与单段 x.y.z;反斜杠、'//'、多段路径一律拒绝)。 这里把函数体抽出来跑输入矩阵 —— 判据仍是「只有站点根 '..' 与单段版本目录名合法」。 */ const vpFn = app.match(/function\s+isValidVersionPath\s*\(([^)]*)\)\s*\{([\s\S]*?)\n {2}\}/); let vpOk = false; let vpDetail = 'isValidVersionPath 未找到'; if (vpFn) { try { const sentinel = (app.match(/CURRENT_PATH_SENTINEL\s*=\s*'([^']*)'/) || [])[1] || '..'; const fn = new Function(vpFn[1], 'CURRENT_PATH_SENTINEL', vpFn[2]); const cases = [ [sentinel, true], ['1.4.1', true], ['10.20.30', true], ['1.4', false], ['latest', false], ['', false], ['1.4.1/x', false], ['x/1.4.1', false], ['../../etc', false], ['1.4.1\\x', false], ['1.4.1//x', false], ]; const bad = cases.filter(([input, want]) => { let got; try { got = !!fn(input, sentinel); } catch (e) { got = 'throw:' + e.message; } return got !== want; }); vpOk = bad.length === 0; vpDetail = bad.map(([i, w]) => `${JSON.stringify(i)} 期望 ${w}`).join('; '); } catch (e) { vpDetail = '抽取函数体失败: ' + e.message; } } check('path validation keeps the single-".." rule(按行为)', vpOk, vpDetail); /* 只有「清单拿不到任何版本」才退化为纯角标(data-single=1 + pointer-events:none); 清单里有版本(哪怕只有一版)就是正常下拉 —— 用户要求下拉始终存在,里面就一项。 */ check('drawer/none-list mode degrades to a plain chip', /data-single/.test(app) && /versionsState\.interactive\s*=\s*!!\(list\s*&&\s*list\.length\s*>\s*0\)/.test(app) && /if \(!list \|\| !list\.length\)/.test(app)); /* 「取不到清单就静默降级」按**行为**判:实现允许多候选(站点根一份 + 快照自带一份,按 rank 定优先级), 但每个 fetch 必须各自吞掉失败、最终以「可用的那份」或 null 回调。 2026-09-20 实测:实现改成多候选后,原先按 `catch(function () { done(null)` 写法匹配的判据误报失败, 而行为完全符合本意(`.catch(...)` 里只留注释 + `.then(settle, settle)`)。 反例(必须仍然失败):删掉那个吞异常的 catch,或把回调改成抛错。 */ check('fetch failure degrades silently(按行为)', /function\s+loadVersionManifest\s*\([\s\S]{0,1600}?\.catch\(function\s*\(\)\s*\{[\s\S]{0,120}?\}\)/.test(app) && /done\((?:best\s*\?\s*best\.list\s*:\s*)?null\)/.test(app)); /* 版本号不得硬编码进 app.js:角标文案只能来自构建数据或清单。 先剥注释再扫:2026-09-20 实测 文档注释里的路由示例(/1.4.1/site/component/button/h5) 被当成硬编码字面量误报 —— 判据本意是「**代码**里不得出现字面量版本号」。 反例(必须仍然失败):代码里出现 var v = 'v2.0.0'; */ const appCode = app.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, ''); const hardcoded = [...appCode.matchAll(/['"]v?(\d+\.\d+\.\d+)['"]/g)].map((m) => m[1]); check('no hardcoded version strings in app.js(已剥注释)', hardcoded.length === 0, hardcoded.join(', ')); /* ---------- 3. 服务器契约 ---------- */ check('dev-server allows snapshot dirs', /VERSION_DIR\s*=\s*\/\^\\d\+\\\.\\d\+\\\.\\d\+\$\//.test(devServer)); check('dev-server falls back to the versioned shell', /spaMatch\[1\]/.test(devServer)); check('dev-server redirects version roots', /verRoot/.test(devServer)); check('nginx has the versioned fallback', /location ~ \^\/\(\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\)\/site\//.test(nginx)); /* 快照缺失时必须是干净的 404,不能是 500。 2026-09-20 实测:本轮发布把已清理的 1.4.1 快照一并移除后,`/1.4.1/site/` 返回 500 —— 容器日志是 "rewrite or internal redirection cycle while internally redirecting to /1.4.1/site/index.html":try_files 的回落目标又匹配回同一 location,形成内部重定向环。 末位带 `=404` 后,前三个参数退化为文件存在性检查,缺快照即 404。 */ check( 'nginx versioned fallback terminates with =404(缺快照 → 404 而非 500)', /try_files\s+\$uri\s+\$uri\/\s+\/\$1\/site\/index\.html\s+=404\s*;/.test(nginx), '期望:try_files $uri $uri/ /$1/site/index.html =404;' ); check('nginx marks versions.json no-store', /versions\\\.json[\s\S]{0,1200}?no-store/.test(nginx)); /* Dockerfile 必须**逐版本显式** COPY 快照到自己的子目录。 2026-09-20 实测踩到的坑:写成「通配源 + 站点根做目标」时(形如 COPY [0-9]x.[0-9]x.[0-9]x 加斜杠 → /usr/share/nginx/html/),COPY 复制的是目录**内容** → 快照的 site、frameworks、 sitemap.xml 原地合并进站点根,用快照那份旧构建覆盖掉本次构建(镜像里 data.js 是快照那次的 generated、根 frameworks 多出改名前的 21 个小写页),且 /1.4.1/ 目录根本没建出来 → nginx 版本 location 自循环 → 500。归档新版本时忘了补 COPY 行,同样会 500 —— 故这里双向卡住:磁盘上的每个快照都要有 COPY 行,Dockerfile 里也不许留已删除版本的 COPY 行。 */ const dockerfile = existsSync(join(ROOT, 'Dockerfile')) ? readFileSync(join(ROOT, 'Dockerfile'), 'utf8') : ''; const snapDirs = existsSync(ROOT) ? readdirSync(ROOT, { withFileTypes: true }) .filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)) .map((e) => e.name) : []; const copyLines = [...dockerfile.matchAll(/^COPY\s+(\d+\.\d+\.\d+)\s+\/usr\/share\/nginx\/html\/\1\s*$/gm)].map((m) => m[1]); check('no wildcard snapshot COPY in Dockerfile(通配会把快照内容合并进站点根)', !/^COPY\s+\[0-9\][^\n]*\s+\/usr\/share\/nginx\/html\/\s*$/m.test(dockerfile)); for (const v of snapDirs) { check(`Dockerfile copies snapshot ${v} into its own dir`, copyLines.includes(v)); } const staleCopies = copyLines.filter((v) => !snapDirs.includes(v)); check('no Dockerfile COPY for missing snapshots', staleCopies.length === 0, staleCopies.join(', ')); /* ---------- 4. 快照契约 ---------- */ if (existsSync(manifestPath)) { for (const v of (manifest && manifest.versions) || []) { if (v.path === '..') continue; const base = join(ROOT, v.path); for (const rel of ['site/index.html', 'site/data.js', 'frameworks', '.design_library/kole-ui/colors_and_type.css', 'VERSION.json']) { check(`snapshot ${v.version} has ${rel}`, existsSync(join(base, rel))); } /* 快照**不带**自己的 versions.json:那份是归档当时写下的,之后新归档的版本不会进去, 实测会让快照页误判成单版本、角标变不可点(回不到最新版)。运行时改为读部署根那份。 */ check(`snapshot ${v.version} carries no stale manifest`, !existsSync(join(base, 'site', 'versions.json'))); const marker = join(base, 'VERSION.json'); if (existsSync(marker)) { try { const info = JSON.parse(readFileSync(marker, 'utf8')); check(`snapshot ${v.version} marker matches`, info.version === v.version, `marker=${info.version}`); } catch (e) { check(`snapshot ${v.version} marker parses`, false, String(e.message).slice(0, 120)); } } /* 规范原文与 agent 报告是仓库内部资料,不该进快照(同部署包排除规则) */ for (const forbidden of ['.design_library/kole-ui/specs', '.design_library/kole-ui/agent-reports']) { check(`snapshot ${v.version} excludes ${forbidden}`, !existsSync(join(base, forbidden))); } const size = (p) => { let total = 0; for (const e of readdirSync(p, { withFileTypes: true })) { const f = join(p, e.name); total += e.isDirectory() ? size(f) : statSync(f).size; } return total; }; const mb = size(base) / 1024 / 1024; check(`snapshot ${v.version} is non-trivial`, mb > 1, `${mb.toFixed(1)} MB`); } } for (const c of checks) console.log(`[versions] ${c.ok ? 'OK' : 'FAIL'} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); if (failures.length) { console.error(`\n[versions] FAIL (${failures.length})`); failures.forEach((f) => console.error('- ' + f)); process.exit(1); } console.log(`\n[versions] OK — ${checks.length} checks passed`);