Files
aurora-admin/tools/verify-mobile-isolation.mjs
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

409 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* verify-mobile-isolation.mjs — 移动端 × PC 端隔离硬门禁
*
* 三组断言(任一条失败即 exit 1):
* A. PC 侧零污染:移动端的东西没有渗进 PC 的任何对外面(目录 / 数据 / 测试 / 分发)
* B. 移动端自洽:索引声明的 6 端文件齐全、契约字段完整、类名与令牌前缀合规
* C. 分发隔离:PC 聚合样式无移动端类,移动端产物无 PC 组件类,package.json exports 两套并存
*
* 为什么需要这个脚本:本次改动新增了一整个平台(移动端)。历史教训(AGENTS.md §八)
* 有 6 次「文档声称完成但代码缺失」——隔离这种"没发生的事"必须靠断言证明,
* 不能靠人眼扫一遍说"看起来没影响"。
*
* 零依赖;退出码 0 = 全部通过。
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, relative, sep } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
let pass = 0;
const failures = [];
function check(ok, id, detail) {
if (ok) {
pass++;
console.log(` PASS ${id}${detail ? ' — ' + detail : ''}`);
} else {
failures.push(id + (detail ? ' — ' + detail : ''));
console.log(` FAIL ${id}${detail ? ' — ' + detail : ''}`);
}
}
function section(title) {
console.log('\n' + title);
}
const rel = (p) => relative(ROOT, p).split(sep).join('/');
const read = (p) => readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist-deploy', '.playwright-mcp', '.zcode']);
/** 递归收集文件(相对仓库根 POSIX 路径) */
function walk(dir, out = []) {
if (!existsSync(dir)) return out;
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) walk(p, out);
else out.push(p);
}
return out;
}
function countFilesMatching(re, files = walk(ROOT)) {
return files.filter((f) => re.test(rel(f)));
}
function readIf(p) {
return existsSync(p) ? read(p) : null;
}
/* ---------- 基准数据 ---------- */
const PC_INDEX = join(ROOT, '.design_library', 'kole-ui', 'components', 'index.json');
const MB_INDEX = join(ROOT, '.design_library', 'kole-ui-mobile', 'components', 'index.json');
const pcIndex = JSON.parse(read(PC_INDEX));
const mbIndex = JSON.parse(read(MB_INDEX));
const pcCount = pcIndex.components.length;
const pcSlugs = new Set(pcIndex.components.map((c) => c.slug));
const mbSlugs = mbIndex.components.map((c) => c.slug);
const allFiles = walk(ROOT);
console.log('移动端 × PC 端隔离门禁');
console.log(`仓库:${ROOT}`);
console.log(`PC 组件 ${pcIndex.components.length} 个 | 移动端组件 ${mbIndex.components.length} 个 × ${mbIndex.ends.length} 端`);
/* ============ A. PC 侧零污染 ============ */
section('A · PC 侧零污染');
const PC_SURFACES = [
'frameworks/',
'site/app.js',
'site/index.html',
'site/style.css',
'site/data.json',
'site/playground.js',
'site/components/',
'.design_library/kole-ui/',
'tests/_runtime.js',
'tests/_behaviors.js',
'tests/index.html',
'tools/build-dist.mjs',
'tools/precompute.mjs',
];
const pcSurfaceFiles = allFiles.filter((f) => {
const r = rel(f);
return PC_SURFACES.some((s) => (s.endsWith('/') ? r.startsWith(s) : r === s));
});
/* A1:PC 实现文件数 = 索引进度 × 5(数量由索引推导,不写死 —— 组件数增长是正常演进,
门禁要卡的是「每个组件 5 端齐全」与「没有移动端文件混进来」,不是某个历史数字) */
const fwFiles = countFilesMatching(/^frameworks\/.+/);
check(fwFiles.length === pcCount * 5, `A1 frameworks/ = 索引组件数 × 5(${pcCount * 5})`, `实际 ${fwFiles.length}`);
/* A2:PC 索引自洽(slug 唯一 + 每个组件都有 frameworksPrefix),且不低于 79 基线 */
check(
pcSlugs.size === pcIndex.components.length && pcIndex.components.every((c) => !!c.frameworksPrefix),
'A2 PC 索引自洽(slug 唯一 / 每项有 frameworksPrefix)',
`${pcIndex.components.length} 个组件`
);
check(pcIndex.components.length >= 79, 'A2b PC 组件数未低于基线 79', `实际 ${pcIndex.components.length}`);
/* A3:PC 契约目录 = 契约数 + index.json */
const pcContracts = countFilesMatching(/^\.design_library\/kole-ui\/components\/.+\.json$/);
check(pcContracts.length === pcCount + 1, `A3 PC 契约目录 = ${pcCount} 契约 + index.json`, `实际 ${pcContracts.length}`);
/* A4:PC 对外数据不含任何移动端痕迹 */
const dataJson = readIf(join(ROOT, 'site', 'data.json'));
const dataJs = readIf(join(ROOT, 'site', 'data.js'));
check(!!dataJson && dataJson.indexOf('kole-m-') < 0, 'A4a site/data.json 无 kole-m- 痕迹');
check(!!dataJs && dataJs.indexOf('kole-m-') < 0, 'A4b site/data.js 无 kole-m- 痕迹');
if (dataJson) {
const parsed = JSON.parse(dataJson);
check(
(parsed.components || []).length === pcCount,
`A4c site/data.json 组件数与索引一致(${pcCount})`,
`实际 ${(parsed.components || []).length}`
);
}
const dataMobileJson = readIf(join(ROOT, 'site', 'm', 'data.mobile.json'));
check(!!dataMobileJson, 'A4d site/m/data.mobile.json 存在(移动端自包含数据)');
/* A5:PC 的**实现与数据面**零 `kole-m-` 命中。
判据收窄说明(2026-09-20):PC 侧的**跨平台说明文档**(如 `.design_library/kole-ui/icons/ICON-SPEC.md`)
会提到移动端令牌(「移动端 --kole-m-icon-size 可被业务覆盖」)—— 那是文档在描述另一端,不是越界实现。
故这里跳过 `.md` / `.txt` 这类说明性文件,只对实现(css/js/jsx/vue/html/json)与数据面判命中。
越界实现仍被 A1/A2/A3/A4/A6 与命名空间断言拦住。 */
const pcImplFiles = pcSurfaceFiles.filter((f) => /\.(css|js|jsx|vue|html|json)$/i.test(rel(f)));
const mbHits = pcImplFiles.filter((f) => {
try {
return read(f).indexOf('kole-m-') >= 0;
} catch {
return false;
}
});
check(
mbHits.length === 0,
'A5 PC 实现与数据面零 kole-m- 命中',
mbHits.length ? mbHits.map(rel).join(', ') : `扫描 ${pcImplFiles.length} 个文件(说明性 .md/.txt 不计)`
);
/* A6:PC 面不得引用移动端目录 */
const crossRefs = pcSurfaceFiles.filter((f) => {
try {
const t = read(f);
return t.includes('frameworks-mobile/') || t.includes('site/m/') || t.includes('kole-ui-mobile');
} catch {
return false;
}
});
check(crossRefs.length === 0, 'A6 PC 面零移动端目录引用', crossRefs.length ? crossRefs.map(rel).join(', ') : null);
/* A7:PC 组件测试页数与索引一致(_collect/index/template 等不计入) */
const pcTestPages = countFilesMatching(/^tests\/[a-z0-9]+\.html$/).filter(
(f) => !['tests/index.html'].includes(rel(f))
);
check(pcTestPages.length === pcCount, `A7 PC 测试页 = 索引组件数(${pcCount})`, `实际 ${pcTestPages.length}`);
/* A8:slug 不撞名 */
const overlap = mbSlugs.filter((s) => pcSlugs.has(s));
check(overlap.length === 0, 'A8 移动端 slug 与 PC 无撞名', overlap.length ? overlap.join(', ') : null);
/* A9:PC 分发产物(若已构建)与索引一致 */
const pcManifest = readIf(join(ROOT, 'dist', 'manifest.json'));
if (pcManifest) {
const mf = JSON.parse(pcManifest);
check((mf.components || []).length === pcCount, `A9 dist/manifest.json = 索引组件数(${pcCount})`, `实际 ${(mf.components || []).length}`);
} else {
console.log(' SKIP A9 dist/manifest.json 未构建(先跑 npm run build:dist)');
}
/* A10:PC 回归报告页数与索引一致(若存在) */
const pcReport = readIf(join(ROOT, 'tests', 'report.json'));
if (pcReport) {
const rp = JSON.parse(pcReport);
check(rp.pages === pcCount, `A10 tests/report.json 页数 = 索引组件数(${pcCount})`, `实际 ${rp.pages}`);
} else {
console.log(' SKIP A10 tests/report.json 不存在(先跑 npm run regression)');
}
/* ============ B. 移动端自洽 ============ */
section('B · 移动端自洽');
/* B1:6 端文件齐全 */
const missingEnds = [];
for (const c of mbIndex.components) {
for (const end of mbIndex.ends) {
const f = c.files && c.files[end];
if (!f || !existsSync(join(ROOT, 'frameworks-mobile', f))) missingEnds.push(`${c.slug}/${end}`);
}
}
check(
missingEnds.length === 0,
`B1 移动端 ${mbIndex.components.length} × ${mbIndex.ends.length} 端文件齐全`,
missingEnds.length ? missingEnds.join(', ') : `${mbIndex.components.length * mbIndex.ends.length} 个文件`
);
/* B1b(2026-09-20 新增):**反向断言** —— 磁盘上的实现文件必须都能在索引里找到。
为什么要它:B1 只查「索引里的组件有没有文件」,不查「磁盘上的文件有没有进索引」。
于是子 agent 产出的组件在合并索引前**完全不被任何门禁检查**(它们不在索引里),
属静默缺口 —— 与本仓库历史上「文档声称完成但代码缺失」同一族。
两个批次子 agent 独立报了这条,故补上。 */
const indexedPrefixes = new Set(mbIndex.components.map((c) => c.frameworksPrefix));
const onDiskPrefixes = new Set(
readdirSync(join(ROOT, 'frameworks-mobile'))
.filter((f) => /\.(css|html|jsx|uniapp\.vue|vue2\.vue|vue3\.vue)$/.test(f))
.map((f) => f.split('.')[0])
);
const unindexed = [...onDiskPrefixes].filter((p) => !indexedPrefixes.has(p)).sort();
check(
unindexed.length === 0,
`B1b 磁盘实现文件全部已登记(${onDiskPrefixes.size} 个前缀)`,
unindexed.length ? `未登记:${unindexed.join(', ')}(跑 node tools/merge-mobile-batch.mjs 合并)` : null
);
/* B2:契约字段完整 */
const CONTRACT_REQUIRED = [
'slug',
'name',
'sourceKind',
'provenance',
'variantDimensions',
'representativeVariants',
'anatomy',
'usageHints',
'doNotInvent',
'unknowns',
];
const badContracts = [];
for (const c of mbIndex.components) {
const p = join(ROOT, '.design_library', 'kole-ui-mobile', c.contract);
if (!existsSync(p)) {
badContracts.push(`${c.slug}: 缺契约文件`);
continue;
}
const ct = JSON.parse(read(p));
for (const k of CONTRACT_REQUIRED) {
if (ct[k] === undefined || ct[k] === null) badContracts.push(`${c.slug}: 缺字段 ${k}`);
}
if (ct.provenance !== 'authored-in-repo') badContracts.push(`${c.slug}: provenance 必须为 authored-in-repo`);
}
check(badContracts.length === 0, 'B2 移动端契约字段完整且 provenance 诚实', badContracts.join('; ') || null);
/* B3:移动端 CSS 只用 kole-m- 类 + 状态类,且无硬编码 hex */
const mbCssFiles = countFilesMatching(/^frameworks-mobile\/.+\.css$/);
const cssProblems = [];
for (const f of mbCssFiles) {
const t = read(f);
const cls = t.match(/\.kole-[a-zA-Z0-9_-]+/g) || [];
cls.forEach((c) => {
const bare = c.slice(1);
if (!bare.startsWith('kole-m-')) cssProblems.push(`${rel(f)}: 非移动端前缀类 .${bare}`);
});
const stateCls = t.match(/\.is-[a-zA-Z0-9_-]+/g) || [];
stateCls.forEach((c) => {
if (!/^\.is-[a-z-]+$/.test(c)) cssProblems.push(`${rel(f)}: 状态类格式异常 ${c}`);
});
const hex = t.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
if (hex.length) cssProblems.push(`${rel(f)}: 硬编码颜色 ${hex.join(' ')}`);
}
check(cssProblems.length === 0, `B3 移动端 CSS 前缀与令牌合规(${mbCssFiles.length} 文件)`, cssProblems.slice(0, 6).join('; ') || null);
/* B4:移动端 CSS 引用的令牌必须真实存在 */
const tokenNames = new Set(
(read(join(ROOT, '.design_library', 'kole-ui', 'colors_and_type.css')).match(/--kole-[a-z0-9-]+/g) || []).concat(
read(join(ROOT, '.design_library', 'kole-ui-mobile', 'colors_and_type.css')).match(/--kole-m-[a-z0-9-]+/g) || []
)
);
const badTokens = [];
for (const f of mbCssFiles) {
(read(f).match(/var\((--kole-[a-z0-9-]+)/g) || []).forEach((v) => {
const name = v.slice(4);
if (!/^--kole-(m-[a-z0-9-]+|[a-z0-9-]+)$/.test(name)) badTokens.push(`${rel(f)}: ${name}`);
});
}
check(badTokens.length === 0, 'B4 移动端 CSS 令牌命名合规', badTokens.slice(0, 6).join('; ') || null);
/* B5:演示页引用移动端令牌层,不直接引 PC 令牌 */
const mbHtml = countFilesMatching(/^frameworks-mobile\/.+\.html$/);
const htmlProblems = [];
for (const f of mbHtml) {
const t = read(f);
if (!t.includes('kole-ui-mobile/colors_and_type.css')) htmlProblems.push(`${rel(f)}: 未引用移动端令牌层`);
if (t.includes('kole-ui/colors_and_type.css')) htmlProblems.push(`${rel(f)}: 直引 PC 令牌层(应走移动端令牌层)`);
if (!t.includes('class="demo"') && !t.includes("class='demo'")) htmlProblems.push(`${rel(f)}: 缺 .demo 被测范围容器`);
}
check(htmlProblems.length === 0, `B5 移动端演示页令牌与结构合规(${mbHtml.length} 页)`, htmlProblems.slice(0, 6).join('; ') || null);
/* B6:演示页声明的 data-behavior 动词必须在移动端行为库里存在 */
const mbBehaviors = read(join(ROOT, 'tests', 'mobile', '_behaviors.js'));
const knownVerbs = new Set((mbBehaviors.match(/'([a-z-]+)':\s*function \(ctx, trig, args\)/g) || []).map((s) => s.split("'")[1]));
const badVerbs = [];
for (const f of mbHtml) {
const t = read(f);
for (const m of t.matchAll(/data-behavior="([a-z][a-z-]*):/g)) {
if (!knownVerbs.has(m[1])) badVerbs.push(`${rel(f)}: ${m[1]}`);
}
}
check(badVerbs.length === 0, 'B6 演示页行为动词均有实现', badVerbs.join('; ') || `已实现动词 ${knownVerbs.size} 个`);
/* B7:生成物齐全 */
const generated = [
'site/m/index.html',
'site/m/style.css',
'site/m/data.mobile.json',
'tests/mobile/index.html',
'tests/mobile/_collect.html',
'dist/mobile/manifest.json',
'dist/mobile/tokens/tokens.css',
'dist/mobile/components/index.css',
'dist/mobile/uniapp/index.js',
];
const missingGen = generated.filter((p) => !existsSync(join(ROOT, p)));
for (const c of mbIndex.components) {
if (!existsSync(join(ROOT, 'site', 'm', 'component', c.slug + '.html'))) missingGen.push(`site/m/component/${c.slug}.html`);
if (!existsSync(join(ROOT, 'tests', 'mobile', c.slug + '.html'))) missingGen.push(`tests/mobile/${c.slug}.html`);
}
check(missingGen.length === 0, 'B7 移动端生成物齐全', missingGen.slice(0, 6).join(', ') || `${generated.length + mbIndex.components.length * 2} 项`);
/* B8:移动端报告(若存在)与索引一致 */
const mbReport = readIf(join(ROOT, 'tests', 'mobile-report.json'));
if (mbReport) {
const rp = JSON.parse(mbReport);
check(rp.pages === mbIndex.components.length, `B8 tests/mobile-report.json 页数与索引一致`, `报告 ${rp.pages} / 索引 ${mbIndex.components.length}`);
} else {
console.log(' SKIP B8 tests/mobile-report.json 不存在(先跑 npm run regression:mobile)');
}
/* ============ C. 分发隔离 ============ */
section('C · 分发隔离');
const distPcIndex = readIf(join(ROOT, 'dist', 'components', 'index.css'));
check(
!distPcIndex || distPcIndex.indexOf('kole-m-') < 0,
'C1 PC 样式聚合无移动端类',
distPcIndex ? null : 'dist/components/index.css 未构建'
);
const distMbIndex = readIf(join(ROOT, 'dist', 'mobile', 'components', 'index.css'));
check(!!distMbIndex && distMbIndex.indexOf('kole-m-') >= 0, 'C2 移动端样式聚合存在且含 kole-m- 类');
const distMbManifest = readIf(join(ROOT, 'dist', 'mobile', 'manifest.json'));
if (distMbManifest) {
const mf = JSON.parse(distMbManifest);
/* 判据(2026-09-20 修正):不再要求「无 PC slug」(同名是允许的),改为
「slug 集合与移动端索引完全一致」——能同时抓到漏项与串入别的东西。 */
const mfSlugs = (mf.components || []).map((c) => c.slug).sort().join(',');
const idxSlugs = mbSlugs.slice().sort().join(',');
check(mfSlugs === idxSlugs, 'C3 dist/mobile/manifest.json 的 slug 集合与索引一致', mfSlugs === idxSlugs ? `${mfSlugs.split(',').length} 个` : `manifest: ${mfSlugs} / 索引: ${idxSlugs}`);
const entries = (mf.components || []).flatMap((c) => Object.values(c.sha256 || {}));
check(entries.every((h) => /^[0-9a-f]{16}$/.test(h)), 'C4 manifest 各端源码校验和齐全');
} else {
console.log(' SKIP C3/C4 dist/mobile/manifest.json 未构建');
}
/* C5:unispp PC 列(若已构建)不含移动端类 */
const distUniappPc = readIf(join(ROOT, 'dist', 'uniapp-pc', 'manifest.json'));
if (distUniappPc) {
const mf = JSON.parse(distUniappPc);
check(mf.platform === 'pc' && mf.end === 'uniapp' && (mf.components || []).length > 0, 'C5 dist/uniapp-pc/manifest.json 平台/端标注正确', `${(mf.components || []).length} 个组件`);
const srcProblems = [];
for (const c of mf.components) {
const t = readIf(join(ROOT, 'frameworks-uniapp-pc', c.file));
if (t && t.indexOf('kole-m-') >= 0) srcProblems.push(c.file);
}
check(srcProblems.length === 0, 'C6 PC × uni-app 端无 kole-m- 前缀', srcProblems.join(', ') || null);
} else {
console.log(' SKIP C5/C6 dist/uniapp-pc 未构建(先跑 npm run build:uniapp)');
}
/* C7:package.json exports 两套并存 */
const pkg = JSON.parse(read(join(ROOT, 'package.json')));
const exp = pkg.exports || {};
const needPc = ['./tokens.css', './components/*', './react', './vue3', './vue2'];
const needMb = ['./mobile', './mobile/tokens.css', './mobile/components/*', './mobile/react', './mobile/vue3', './mobile/vue2', './mobile/uniapp'];
const missExp = [...needPc, ...needMb].filter((k) => !exp[k]);
check(missExp.length === 0, 'C7 package.json exports 含 PC 与移动端两套入口', missExp.join(', ') || `${Object.keys(exp).length} 条`);
const dep = Object.keys(pkg.dependencies || {});
check(dep.length === 0, 'C8 零运行时依赖保持不变', dep.join(', ') || null);
/* ============ 汇总 ============ */
console.log('\n────────────────────────────');
if (failures.length) {
console.error(`[FAIL] ${failures.length} 条隔离断言失败(通过 ${pass} 条):`);
failures.forEach((f) => console.error(' - ' + f));
process.exit(1);
}
console.log(`[OK] 隔离门禁全部通过(${pass} 条断言)`);