#!/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} 条断言)`);