Regression / regression (push) Canceled after 0s
## 品牌标识(本次会话) 起因:品牌此前没有任何图形标识 —— 唯一 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 未做部分)
323 lines
15 KiB
JavaScript
323 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* verify-icons.mjs — 图标系统的静态断言门禁(ICON-SPEC.md §八)
|
||
*
|
||
* 为什么要有它(本文件此前只被引用、从未存在):
|
||
* ICON-SPEC.md §八与 build-icons.mjs / fetch-icon-sources.mjs / inject-icon-table.mjs
|
||
* 的注释里共 **6 处**引用 `tools/verify-icons.mjs`,但仓库里没有这个文件 ——
|
||
* 于是「规格声明的验收门」实际不可执行,§二 的 5 条归一化修正与 §三 的别名约束
|
||
* 全靠构建期自觉,没有回归保护。本脚本把它补齐。
|
||
*
|
||
* 判据来源(关键:不能自证):
|
||
* 数字真源是**上游产物** registry.json(2576 个 icon 的路径数据)与 manifest.json
|
||
* (名字 / 层 / 分组 / viewBox / 模式)。端实现只被**检查**,不被当成判据 ——
|
||
* 否则「实现悄悄改错」会让判据跟着一起错(本仓库 B-<target> 门禁的实测教训)。
|
||
*
|
||
* 覆盖 ICON-SPEC.md §八 的 7 条:
|
||
* V1 产物齐全且互相一致(manifest ↔ registry 双向)
|
||
* V2 无 `view-box` 拼写残留(§二 修正 1:上游把它写成无效属性)
|
||
* V3 描边宽度全部 1.5(§二 修正 2)
|
||
* V4 core 层图标真的能从 core.js 渲染出非空 SVG
|
||
* V5 别名全部指向真实图标(§三 3.2)
|
||
* V6 每个端实现的 name 查找路径都能落到 registry(不出现「端上有 API 但取不到图标」)
|
||
* V7 分组覆盖率 ≥ 75%(低于则说明分组规则退化)
|
||
*
|
||
* 零依赖;退出码 0 = 全部通过。
|
||
*/
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { createRequire } from 'node:module';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const ICON_DIR = join(ROOT, '.design_library', 'kole-ui', 'icons');
|
||
const read = (p) => readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
|
||
const readIf = (p) => (existsSync(p) ? read(p) : null);
|
||
|
||
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 : ''}`);
|
||
}
|
||
}
|
||
|
||
console.log('图标系统静态门禁(ICON-SPEC.md §八)');
|
||
|
||
/* ---------- 载入真源 ---------- */
|
||
|
||
const REGISTRY = join(ICON_DIR, 'registry.json');
|
||
const MANIFEST = join(ICON_DIR, 'manifest.json');
|
||
const ALIASES = join(ICON_DIR, 'aliases.json');
|
||
const CORE_JS = join(ICON_DIR, 'core.js');
|
||
const VIEWBOX_JS = join(ICON_DIR, 'viewbox.js');
|
||
const SRC = join(ICON_DIR, 'sources.json');
|
||
|
||
for (const f of [REGISTRY, MANIFEST, ALIASES, CORE_JS, VIEWBOX_JS]) {
|
||
if (!existsSync(f)) {
|
||
console.error(`[FATAL] 缺产物 ${f} —— 先跑 node tools/build-icons.mjs`);
|
||
process.exit(2);
|
||
}
|
||
}
|
||
|
||
const registry = JSON.parse(read(REGISTRY));
|
||
const manifest = JSON.parse(read(MANIFEST));
|
||
const aliases = JSON.parse(read(ALIASES)).aliases;
|
||
const REG = registry.icons;
|
||
const REG_NAMES = Object.keys(REG);
|
||
|
||
const require2 = createRequire(import.meta.url);
|
||
const runtime = require2(CORE_JS);
|
||
|
||
console.log(` 真源:registry ${REG_NAMES.length} · manifest ${manifest.icons.length} · 别名 ${Object.keys(aliases).length} · core ${runtime.coreCount}`);
|
||
|
||
/* ---------- V1 产物齐全且互相一致(manifest ↔ registry 双向) ---------- */
|
||
|
||
const MAN_NAMES = manifest.icons.map((i) => i.name);
|
||
const manSet = new Set(MAN_NAMES);
|
||
const regSet = new Set(REG_NAMES);
|
||
const onlyManifest = [...manSet].filter((n) => !regSet.has(n));
|
||
const onlyRegistry = [...regSet].filter((n) => !manSet.has(n));
|
||
check(MAN_NAMES.length === new Set(MAN_NAMES).size, 'V1a manifest 内名字无重复', `${MAN_NAMES.length} 个`);
|
||
check(
|
||
onlyManifest.length === 0 && onlyRegistry.length === 0,
|
||
'V1b manifest ↔ registry 双向一致',
|
||
onlyManifest.length || onlyRegistry.length
|
||
? `只在 manifest:${onlyManifest.slice(0, 4).join(',') || '无'};只在 registry:${onlyRegistry.slice(0, 4).join(',') || '无'}`
|
||
: `${REG_NAMES.length} 个双向对齐`
|
||
);
|
||
|
||
/* viewBox 表:manifest 里非 24 网格的才是例外,必须与 viewbox.js 逐条对齐 */
|
||
const VBOX_EX = JSON.parse(
|
||
read(VIEWBOX_JS).replace(/^[\s\S]*?=\s*/, '').replace(/;\s*$/, '')
|
||
);
|
||
const manVb = new Map(manifest.icons.map((i) => [i.name, i.viewBox]));
|
||
const exNames = Object.keys(VBOX_EX).sort();
|
||
const exMismatch = exNames.filter((n) => manVb.get(n) !== VBOX_EX[n]);
|
||
const missingEx = [...manVb].filter(([n, v]) => v !== '0 0 24 24' && !(n in VBOX_EX)).map(([n]) => n);
|
||
check(
|
||
exMismatch.length === 0 && missingEx.length === 0,
|
||
'V1c viewBox 例外表与 manifest 逐条一致',
|
||
exMismatch.length || missingEx.length
|
||
? `值不符:${exMismatch.slice(0, 4).join(',') || '无'};漏登记:${missingEx.slice(0, 4).join(',') || '无'}`
|
||
: `${exNames.length} 条例外`
|
||
);
|
||
|
||
/* ---------- V2 无 view-box 拼写残留 ---------- */
|
||
|
||
const viewBoxBad = [];
|
||
for (const f of ['registry.json', 'manifest.json', 'sources.json', 'core.js', 'viewbox.js']) {
|
||
const t = readIf(join(ICON_DIR, f));
|
||
if (t && /view-box/.test(t)) viewBoxBad.push(f);
|
||
}
|
||
check(viewBoxBad.length === 0, 'V2 无 view-box 无效拼写残留(§二 修正 1)', viewBoxBad.join(', ') || `扫描 5 个产物文件`);
|
||
|
||
/* ---------- V3 描边宽度全部 1.5 ---------- */
|
||
|
||
const swProblems = [];
|
||
if (registry.strokeWidth !== 1.5) swProblems.push(`registry.strokeWidth=${registry.strokeWidth}`);
|
||
if (manifest.strokeWidth !== 1.5) swProblems.push(`manifest.strokeWidth=${manifest.strokeWidth}`);
|
||
if (runtime.strokeWidth !== 1.5) swProblems.push(`core.js strokeWidth=${runtime.strokeWidth}`);
|
||
/* 产物里不许再出现旧宽度 2 的渲染字面量 */
|
||
const coreSrc = read(CORE_JS);
|
||
if (/stroke-width="2"/.test(coreSrc)) swProblems.push('core.js 含 stroke-width="2"');
|
||
check(swProblems.length === 0, 'V3 描边宽度全部 1.5(§二 修正 2)', swProblems.join('; ') || null);
|
||
|
||
/* ---------- V4 core 层图标真的能渲染出非空 SVG ---------- */
|
||
|
||
const coreNames = Object.keys(runtime.core).sort();
|
||
const renderBad = [];
|
||
for (const n of coreNames) {
|
||
const svg = runtime.svg(n);
|
||
if (!svg) { renderBad.push(`${n}:空串`); continue; }
|
||
if (!/<path d="[^"]/.test(svg)) renderBad.push(`${n}:无 path`);
|
||
if (!/viewBox="0 0 [0-9.]+ [0-9.]+"/.test(svg)) renderBad.push(`${n}:无 viewBox`);
|
||
/* 非 24 网格必须命中例外表,否则会被拉伸变形 */
|
||
const want = VBOX_EX[n] || '0 0 24 24';
|
||
if (!svg.includes(`viewBox="${want}"`)) renderBad.push(`${n}:viewBox 应为 ${want}`);
|
||
}
|
||
check(renderBad.length === 0, 'V4 core 层图标可渲染出非空 SVG', renderBad.slice(0, 5).join('; ') || `${coreNames.length} 个`);
|
||
check(
|
||
coreNames.length === runtime.coreCount,
|
||
'V4b core 计数自洽',
|
||
`coreCount=${runtime.coreCount} / 实际 ${coreNames.length}`
|
||
);
|
||
|
||
/* ---------- V5 别名全部指向真实图标 ---------- */
|
||
|
||
const badAlias = [];
|
||
for (const [a, target] of Object.entries(aliases)) {
|
||
if (!regSet.has(target)) badAlias.push(`${a}→${target}(不存在)`);
|
||
if (regSet.has(a)) badAlias.push(`${a}(别名遮蔽了真图标)`);
|
||
}
|
||
check(badAlias.length === 0, 'V5 别名全部指向真实图标且不遮蔽(§三 3.2)', badAlias.slice(0, 5).join('; ') || `${Object.keys(aliases).length} 条`);
|
||
|
||
/* ---------- V6 每个端实现的 name 查找路径都能落到 registry ---------- */
|
||
|
||
/* 端文件清单:与 tools/inject-icon-table.mjs 的 FILES 同源(那里是唯一注入入口) */
|
||
const injector = read(join(ROOT, 'tools', 'inject-icon-table.mjs'));
|
||
const filesBlock = injector.match(/const FILES = \[([\s\S]*?)\];/);
|
||
const END_FILES = filesBlock
|
||
? [...filesBlock[1].matchAll(/\['([^']+)',\s*'(html|js)'\]/g)].map((m) => ({ rel: m[1], kind: m[2] }))
|
||
: [];
|
||
|
||
if (!END_FILES.length) {
|
||
check(false, 'V6 端实现清单可解析', '未能从 tools/inject-icon-table.mjs 解析出 FILES');
|
||
} else {
|
||
const endProblems = [];
|
||
const endPending = [];
|
||
let checkedEnds = 0;
|
||
|
||
for (const { rel, kind } of END_FILES) {
|
||
const abs = join(ROOT, rel);
|
||
if (!existsSync(abs)) { endProblems.push(`${rel}(文件缺失)`); continue; }
|
||
const t = read(abs);
|
||
const [b, e] = kind === 'html'
|
||
? ['<!-- === KOLE_ICON_TABLE:BEGIN === -->', '<!-- === KOLE_ICON_TABLE:END === -->']
|
||
: ['/* === KOLE_ICON_TABLE:BEGIN === */', '/* === KOLE_ICON_TABLE:END === */'];
|
||
if (!t.includes(b) || !t.includes(e)) { endProblems.push(`${rel}(缺标记对)`); continue; }
|
||
|
||
const bi = t.indexOf(b);
|
||
const ei = t.indexOf(e);
|
||
const block = t.slice(bi + b.length, ei);
|
||
/* 标记块内部必须已有数据(未注入 = 端上取不到图标,正是 §八 第 6 条要拦的) */
|
||
const jsonText = kind === 'html'
|
||
? (block.match(/<script type="application\/json" id="kole-icon-data">([\s\S]*?)<\/script>/) || [])[1]
|
||
: (block.match(/const KOLE_ICON_DATA = ([\s\S]*?);\s*$/) || [])[1];
|
||
if (!jsonText) { endPending.push(rel); continue; }
|
||
|
||
let data;
|
||
try {
|
||
data = JSON.parse(jsonText.replace(/\\u003c/g, '<'));
|
||
} catch (err) {
|
||
endProblems.push(`${rel}(数据块 JSON 解析失败: ${String(err.message).slice(0, 40)})`);
|
||
continue;
|
||
}
|
||
checkedEnds++;
|
||
|
||
/* ① 端上表必须是「终态表」:别名已折叠 —— 每个名字都能直接查到路径 */
|
||
const table = data.i || {};
|
||
const names = Object.keys(table);
|
||
|
||
/* ② 每个端名字必须能落到 registry(canonical 直接命中;别名落到其 target) */
|
||
const unlanded = [];
|
||
for (const n of names) {
|
||
const target = regSet.has(n) ? n : aliases[n];
|
||
if (!target || !regSet.has(target)) { unlanded.push(n); continue; }
|
||
/* ③ 路径数据必须与真源逐字节一致(防端上手抄漂移) */
|
||
if (table[n] !== REG[target]) unlanded.push(`${n}(数据漂移)`);
|
||
}
|
||
if (unlanded.length) endProblems.push(`${rel}: ${unlanded.slice(0, 3).join(',')}${unlanded.length > 3 ? ` 等 ${unlanded.length} 个` : ''}`);
|
||
|
||
/* ④ 描边宽度与 viewBox 例外表必须与真源一致 */
|
||
if (data.sw !== 1.5) endProblems.push(`${rel}: sw=${data.sw}(应 1.5)`);
|
||
if (data.vb && JSON.stringify(Object.keys(data.vb).sort()) !== JSON.stringify(exNames)) {
|
||
const got = new Set(Object.keys(data.vb));
|
||
const miss = exNames.filter((n) => !got.has(n));
|
||
const extra = [...got].filter((n) => !exNames.includes(n));
|
||
endProblems.push(`${rel}: vb 例外表不符(漏 ${miss.length} / 多 ${extra.length})`);
|
||
}
|
||
/* ⑤ 端上名字覆盖率:必须含 core 全部 + 「能折叠的别名」。
|
||
注意**不能**要求 241 条别名全在端上 —— ICON-SPEC §五 明确分层:
|
||
core 151 内联,standard/extended 按需。别名若指向非 core 的 canonical
|
||
(如 circle-plus-outline → circle-plus),注入器按设计不折叠它
|
||
(注入器的条件是 `merged[target]` 存在)。所以这里只要求
|
||
「core 名 + 目标在 core 内的别名」齐全。 */
|
||
const foldableAliases = Object.keys(aliases).filter(
|
||
(a) => regSet.has(aliases[a]) && coreNames.includes(aliases[a]) && !regSet.has(a)
|
||
);
|
||
const expect = new Set([...coreNames, ...foldableAliases]);
|
||
const missingNames = [...expect].filter((n) => !(n in table));
|
||
if (missingNames.length) endProblems.push(`${rel}: 缺 ${missingNames.length} 个名字(如 ${missingNames.slice(0, 3).join(',')})`);
|
||
}
|
||
|
||
check(
|
||
endProblems.length === 0,
|
||
`V6 各端 name 查找均能落到 registry(${checkedEnds} 个端已注入)`,
|
||
endProblems.slice(0, 6).join('; ') || null
|
||
);
|
||
if (endPending.length) {
|
||
console.log(` SKIP ${endPending.length} 个端尚未注入图标表(先跑 node tools/inject-icon-table.mjs):`);
|
||
endPending.forEach((p) => console.log(' - ' + p));
|
||
}
|
||
}
|
||
|
||
/* ---------- V7 分组覆盖率 ≥ 75% ---------- */
|
||
|
||
const GROUPED_OTHER = new Set(['其他', '未分组', '']);
|
||
const groups = new Set();
|
||
let grouped = 0;
|
||
for (const i of manifest.icons) {
|
||
if (!GROUPED_OTHER.has(i.group)) { grouped++; groups.add(i.group); }
|
||
}
|
||
const cov = grouped / manifest.icons.length;
|
||
check(
|
||
cov >= 0.75,
|
||
'V7 分组覆盖率 ≥ 75%(§八 第 7 条)',
|
||
`${(cov * 100).toFixed(1)}% · ${groups.size} 个分组 · 未分组 ${manifest.icons.length - grouped}`
|
||
);
|
||
|
||
/* ---------- V8 路径数字完整性(防「归一化把坐标改错」) ----------
|
||
*
|
||
* 为什么单列这一条:构建期把路径数字四舍五入到 2 位小数看似无害,但 SVG path 允许
|
||
* 「隐式分隔」—— `q-13.005.48-22.5` 是三个数,靠 `-` 和 `.` 自身当分隔符。
|
||
* 2026-09-20 实测踩到(Element Plus 的 quartz-watch):`-13.005` 被舍成 `-13` 后,
|
||
* 后面的 `.48` 粘成 `-13.48` —— 图形画错,**且不报任何错**,是靠浏览器控制台才发现的。
|
||
*
|
||
* 判据:把 registry 每个路径的**数字序列**抽出来,与 sources.json 的原串逐值比对。
|
||
* 允许 0.005 的四舍五入容差,但**不允许数字个数变化**(个数变了就是粘连/吞并)。
|
||
*/
|
||
{
|
||
const srcRaw = readIf(SRC) ? JSON.parse(read(SRC)) : null;
|
||
const problems = [];
|
||
let compared = 0;
|
||
if (!srcRaw) {
|
||
console.log(' SKIP V8 缺 sources.json(先跑 node tools/fetch-icon-sources.mjs)');
|
||
} else {
|
||
const nums = (d) => (d.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g) || []).map(Number);
|
||
/* 按 **source id + 名字** 建索引:同一个名字可能同时存在于多个上游
|
||
(实测 brush-filled / home-filled 在 tdesign-filled 与 element-plus 里都有),
|
||
而 manifest 记录了最终采用哪个 source —— 必须比同源的那一份,否则是假阳性。 */
|
||
const srcById = new Map(srcRaw.sources.map((s) => [s.id, s.icons]));
|
||
const manifestSource = new Map(manifest.icons.map((i) => [i.name, i.source]));
|
||
for (const [name, packed] of Object.entries(REG)) {
|
||
const srcId = manifestSource.get(name);
|
||
if (!srcId) continue; /* 别名:V5 已保证它指向真实图标 */
|
||
const bag = srcById.get(srcId);
|
||
const icon = bag && bag[name];
|
||
if (!icon) continue;
|
||
compared++;
|
||
const a = nums(packed.split('|').map((s) => s.slice(1)).join(' '));
|
||
const b = nums(icon.d.map((x) => x.p).join(' '));
|
||
if (a.length !== b.length) {
|
||
problems.push(`${name}: 数字个数 ${a.length} ≠ 源 ${b.length}`);
|
||
continue;
|
||
}
|
||
for (let i = 0; i < a.length; i++) {
|
||
if (Math.abs(a[i] - b[i]) > 0.005) {
|
||
problems.push(`${name}: 第 ${i} 个数 ${a[i]} ≠ 源 ${b[i]}`);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
check(problems.length === 0, 'V8 路径数字与源逐值一致(防隐式分隔粘连)',
|
||
problems.slice(0, 5).join('; ') || `比对 ${compared} 个同源同名图标`);
|
||
}
|
||
}
|
||
|
||
/* ---------- 汇总 ---------- */
|
||
|
||
console.log('');
|
||
console.log('未执行:把 core.js / aliases.json 重新从上游拉取核对(需联网,见 tools/fetch-icon-sources.mjs)');
|
||
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} 条断言 · registry ${REG_NAMES.length} 图标)`);
|