#!/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- 门禁的实测教训)。 * * 覆盖 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 (!/ ({ 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 === */']; 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(/