#!/usr/bin/env node /*** @ui-arch CLI:组件库注册表命令行 + 内置 MCP 服务(零依赖,Node >= 18) ***/ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { visualAudit } from '../lib/visual-audit.mjs'; import { auditContrast, formatReport, PAIRS } from '../lib/contrast-audit.mjs'; import { shellPalette } from '../lib/shell-palette.mjs'; import { auditMotion, formatMotionReport } from '../lib/motion-audit.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /*** 自底向上查找仓库根(以 pnpm-workspace.yaml 为界) ***/ function findRepoRoot(start) { let dir = start; for (let i = 0; i < 10; i++) { if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return dir; const parent = path.dirname(dir); if (parent === dir) return process.cwd(); dir = parent; } return process.cwd(); } const ROOT = findRepoRoot(__dirname); const PKG_DIR = path.join(ROOT, 'packages'); const INDEX_DIR = path.join(ROOT, '.ua'); const INDEX_FILE = path.join(INDEX_DIR, 'index.json'); const LLMS_FILE = path.join(ROOT, 'llms.txt'); const LLMS_FULL = path.join(ROOT, 'llms-full.txt'); const RECIPES_FILE = path.join(ROOT, 'packages', 'tools', 'cli', 'recipes.md'); // 组合配方单一来源(人类版与 MCP 共用) const MCP_TOOL_NAMES = ['list_components', 'get_component', 'search_components', 'get_tokens', 'list_recipes', 'get_recipe']; // MCP 工具单一来源:注册表与三处文档都对它校验 /*** 汇总各包 ua.manifest.json 与 package.json 版本(扫描 packages/ 两层目录) ***/ function collectManifests() { const out = []; if (!fs.existsSync(PKG_DIR)) return out; const readManifest = (dir) => { const manifestPath = path.join(dir, 'ua.manifest.json'); if (!fs.existsSync(manifestPath)) return; // 卫语句:无清单不入索引 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const pkgPath = path.join(dir, 'package.json'); let version = '0.0.0'; if (fs.existsSync(pkgPath)) { version = JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version ?? version; } out.push({ ...manifest, version, dir: path.relative(ROOT, dir).split(path.sep).join('/'), }); }; for (const layer of fs.readdirSync(PKG_DIR)) { const layerDir = path.join(PKG_DIR, layer); if (!fs.statSync(layerDir).isDirectory()) continue; readManifest(layerDir); // 兼容单层直放 for (const name of fs.readdirSync(layerDir)) { const dir = path.join(layerDir, name); if (fs.statSync(dir).isDirectory()) readManifest(dir); } } return out; } /*** 内容无变化则跳过写入(幂等,行尾归一化防 CRLF 误判;norm 可剔除时间戳后再比对),避免弄脏工作区 ***/ function writeIfChanged(file, content, norm = (s) => s) { if (fs.existsSync(file) && norm(fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')) === norm(content)) return false; fs.writeFileSync(file, content); return true; } /*** 聚合清单,生成 .ua/index.json 与 llms.txt ***/ function cmdIndex() { const packages = collectManifests(); fs.mkdirSync(INDEX_DIR, { recursive: true }); const index = { $schema: 'ui-arch-registry-index/0.1', generatedAt: new Date().toISOString(), repoRoot: '.', packages, }; // 幂等:剔除 generatedAt 后内容一致即不写入,连续 check 不弄脏工作区 const json = JSON.stringify(index, null, 2) + '\n'; const stripTs = (s) => s.replace(/"generatedAt": "[^"]*"/, '"generatedAt": ""'); const changed = !fs.existsSync(INDEX_FILE) || stripTs(fs.readFileSync(INDEX_FILE, 'utf8').replace(/\r\n/g, '\n')) !== stripTs(json); if (changed) fs.writeFileSync(INDEX_FILE, json); const lines = [ '# @ui-arch 私人组件库索引', '', '> 本文件由 `ua index` 自动生成,请勿手改。机器可读版:`.ua/index.json`。', '> Agent 可通过 `ua mcp`(stdio MCP server)直接查询注册表。', '', ]; for (const p of packages) { lines.push(`## ${p.name} (v${p.version})`, ''); lines.push(`- 类型: ${p.type} · 目录: ${p.dir}`); lines.push(`- 描述: ${p.description}`); if (p.api?.exports?.length) { lines.push('- API:'); for (const e of p.api.exports) lines.push(` - ${e}`); } if (p.usage) lines.push(`- 用法: \`${p.usage}\``); for (const c of p.conventions ?? []) lines.push(`- 约定: ${c}`); lines.push(''); } const recipes = parseRecipes(); // 速览也要给出配方目录:Agent 读 llms.txt 时才知道有跨组件拼装可查 if (recipes.length) { lines.push('## 组合配方(跨组件拼装)', ''); lines.push('> 全文见 `packages/tools/cli/recipes.md` 或 llms-full.txt;MCP 用 `list_recipes` / `get_recipe` 取骨架与要点。', ''); for (const r of recipes) lines.push(`- 配方 ${r.id}:${r.title}(${r.notes.length} 条要点)`); lines.push(''); } writeIfChanged(LLMS_FILE, lines.join('\n')); console.log(`indexed ${packages.length} package(s) -> .ua/index.json, llms.txt${changed ? '' : ' (unchanged, skip)'}`); } /*** 读取注册表索引;缺失则报错退出 ***/ function loadIndex() { if (!fs.existsSync(INDEX_FILE)) { console.error('index not found, run: ua index'); process.exit(1); } return JSON.parse(fs.readFileSync(INDEX_FILE, 'utf8')); } /*** 列出已注册内容:默认列包,--components 改为按包分组列全部组件(id + 标题 + 现场演示锚点) ***/ function cmdList(args = []) { const { packages } = loadIndex(); if (!packages.length) return console.log('(no packages indexed)'); if (args.includes('--json')) { // 机器可读:包 + 组件 id 清单,脚本 / Agent 不必解析文本 console.log(JSON.stringify(packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description, components: (p.docs?.components ?? []).map((c) => c.id) })), null, 2)); return; } if (args.includes('--components')) { let total = 0; for (const p of packages) { const comps = p.docs?.components ?? []; if (!comps.length) continue; total += comps.length; console.log(`\n${p.name} (${comps.length})`); for (const c of comps) console.log(` ${String(c.id).padEnd(16)} ${c.title ?? ''}${c.demo ? ` [${c.demo}]` : ''}`); } return console.log(`\n共 ${total} 个组件;查参数:ua info <组件id>,模糊找:ua search <关键词>`); } for (const p of packages) { const n = p.docs?.components?.length ?? 0; console.log(`${p.name} v${p.version} [${p.type}] ${n} 个组件 ${p.description}`); } } /*** 摊平组件清单(带所属包),供 info / MCP 按组件 id 查询 ***/ function collectComponents(packages) { const list = packages ?? loadIndex().packages; return list.flatMap((pkg) => (pkg.docs?.components ?? []).map((comp) => ({ pkg, comp }))); } /*** 渲染组件的人类可读说明:props 表 + 步骤 + 示例,便于直接抄进代码 ***/ function formatComponent(pkg, comp) { const lines = [`${comp.id} — ${comp.title ?? ''} (${pkg.name}${comp.demo ? `, demo: ${comp.demo}` : ''})`, '']; if ((comp.props ?? []).length) { lines.push('props:'); const width = Math.max(...comp.props.map((x) => (x.n ?? '').length)); for (const x of comp.props) lines.push(` ${(x.n ?? '').padEnd(width)} ${(x.t ?? '').padEnd(30)} ${x.r ? '必填 ' : ' '}${x.d ?? ''}`); lines.push(''); } if ((comp.steps ?? []).length) { lines.push('steps:'); comp.steps.forEach((t, i) => lines.push(` ${i + 1}. ${t}`)); lines.push(''); } if (comp.example) lines.push('example:', ` ${comp.example}`); return lines.join('\n'); } /*** 作用:把组件渲染成可直接贴进 issue / 文档 / 回复的 Markdown 片段(标题 + 参数表 + 用法 + 示例) * 与文本版共用同一份清单数据:Agent 拿到片段就能贴,不必自己从参数表里再抄一遍格式。 ***/ function formatComponentMd(pkg, comp) { const cell = (v) => String(v ?? '').replace(/\|/g, '\\|'); // 联合类型里的竖线会截断表格,必须转义 const lines = [`### ${comp.id} · ${comp.title ?? comp.id}`, '', `\`${pkg.name}\`${comp.demo ? ` · 演示锚点 \`${comp.demo}\`` : ''}`, '']; if ((comp.props ?? []).length) { lines.push('| 参数 | 类型 | 必填 | 说明 |', '| --- | --- | --- | --- |'); for (const x of comp.props) lines.push(`| \`${cell(x.n)}\` | \`${cell(x.t)}\` | ${x.r ? '是' : '否'} | ${cell(x.d)} |`); lines.push(''); } if ((comp.steps ?? []).length) { lines.push('**用法**', ''); comp.steps.forEach((t, i) => lines.push(`${i + 1}. ${t}`)); lines.push(''); } if (comp.example) lines.push('```ts', comp.example, '```', ''); return lines.join('\n'); } /*** 反查某组件出现在哪些配方里:查参数时顺带给出「它通常和谁一起用」的组合线索 ***/ function recipesFor(compId) { const re = new RegExp(`\\b${compId}\\b`); return parseRecipes() .filter((r) => re.test(`${r.code}\n${r.text}`)) .map((r) => ({ id: r.id, title: r.title })); } /*** 编辑距离(截断版):只用来给拼错的名字找相近候选,超过 3 直接当无建议 ***/ function editDistance(a, b) { const prev = Array.from({ length: b.length + 1 }, (_, j) => j); for (let i = 1; i <= a.length; i++) { let diag = prev[0]; prev[0] = i; for (let j = 1; j <= b.length; j++) { const tmp = prev[j]; prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1)); diag = tmp; } } return prev[b.length]; } /*** 未命中时给相近组件 id(编辑距离 ≤ 3),避免只回一句 not found ***/ function nearIds(name) { return collectComponents() .map((x) => [x.comp.id, editDistance(String(name).toLowerCase(), x.comp.id.toLowerCase())]) .filter(([, d]) => d <= 3) .sort((x, y) => x[1] - y[1]) .slice(0, 3) .map(([id]) => id); } /*** 组件的可检索字段:权重从高到低(id > 标题 > 参数名 > 参数说明 > 步骤),search 与 MCP 共用一份 ***/ function searchFields(comp) { const props = comp.props ?? []; return [ { label: 'id', weight: 3, text: String(comp.id ?? '') }, { label: '标题', weight: 2, text: String(comp.title ?? '') }, { label: '参数', weight: 1, text: props.map((p) => String(p.n ?? '')).join(' ') }, { label: '参数说明', weight: 0.8, text: props.map((p) => String(p.d ?? '')).join(' ') }, { label: '步骤', weight: 0.5, text: (comp.steps ?? []).join(' ') }, ].map((f) => ({ label: f.label, weight: f.weight, text: f.text.toLowerCase() })); } /*** 单个关键词在组件上的最佳命中:返回最高档字段(id 全等额外抬到最前),未命中 score 为 0 ***/ function matchOf(kw, comp) { let best = { score: 0, label: '' }; for (const f of searchFields(comp)) { if (!f.text.includes(kw)) continue; const score = f.label === 'id' && f.text === kw ? 4 : f.weight; // id 全等单独一档,避免被 id 含稀释 if (score > best.score) best = { score, label: f.label }; } return best; } /*** 按关键词搜组件:多词取最弱命中(AND 语义),并标出该词命中的字段,便于判断为何被召回 ***/ /*** 单个配方对多关键词的命中:标题 3 / 组件 2 / 要点 1,返回最弱档(与组件搜索同一排序口径),未命中返回 null ***/ function recipeMatch(kws, r) { const fields = [ { label: '配方标题', weight: 3, text: String(r.title ?? '').toLowerCase() }, { label: '配方组件', weight: 2, text: recipeComps(r).join(' ').toLowerCase() }, { label: '配方要点', weight: 1, text: (r.notes ?? []).join(' ').toLowerCase() }, ]; let weakest = null; for (const kw of kws) { let best = null; for (const f of fields) if (f.text.includes(kw) && (!best || f.weight > best.weight)) best = { ...f, keyword: kw }; if (!best) return null; // 任一词不中即淘汰 if (!weakest || best.weight < weakest.weight) weakest = best; } return weakest; } function cmdSearch(words = [], args = []) { const kws = [...new Set(words.map((w) => String(w).toLowerCase().trim()).filter(Boolean))]; if (!kws.length) return console.error('usage: ua search <关键词...> [--json]'); const hits = collectComponents() .map(({ pkg, comp }) => { const ms = kws.map((kw) => ({ kw, ...matchOf(kw, comp) })); if (ms.some((m) => !m.score)) return null; // 任一词不中即淘汰 const weakest = ms.reduce((a, b) => (b.score < a.score ? b : a)); // 排序与提示都看最弱那一词 return { pkg, comp, score: weakest.score, field: weakest.label, keyword: weakest.kw }; }) .filter(Boolean) .sort((a, b) => b.score - a.score || String(a.comp.id).localeCompare(String(b.comp.id))); const recipeHits = parseRecipes() // 配方域一并搜:跨组件诉求(怎么拼)的直达载体 .map((r) => ({ r, m: recipeMatch(kws, r) })) .filter((x) => x.m) .sort((a, b) => b.m.weight - a.m.weight || Number(a.r.id) - Number(b.r.id)); if (!hits.length && !recipeHits.length) { const near = nearIds(kws.join('')); console.error(`no match: ${words.join(' ')}${near.length ? `(相近:${near.join(' / ')})` : ''}`); return process.exit(1); } if (args.includes('--json')) { return console.log( JSON.stringify( { components: hits.map((h) => ({ package: h.pkg.name, ...h.comp, matched: { field: h.field, keyword: h.keyword } })), recipes: recipeHits.map(({ r, m }) => ({ id: r.id, title: r.title, components: recipeComps(r), matched: { field: m.label, keyword: m.keyword } })), }, null, 2, ), ); } for (const h of hits) { const why = h.field === 'id' || h.field === '标题' ? '' : ` ← ${h.field}含「${h.keyword}」`; console.log(`${String(h.comp.id).padEnd(16)} ${h.comp.title ?? ''} (${h.pkg.name})${why}`); } for (const { r, m } of recipeHits) { const why = m.label === '配方标题' ? '' : ` ← ${m.label}含「${m.keyword}」`; console.log(`配方 ${r.id.padEnd(10)} ${r.title}${why} → 看骨架:ua recipes ${r.id}`); } const parts = []; if (hits.length) parts.push(`${hits.length} 个组件`); if (recipeHits.length) parts.push(`${recipeHits.length} 条配方`); console.log(`\n共 ${parts.join('、')}命中;查参数:ua info <组件id>,看骨架:ua recipes <编号>`); } /*** 输出单包或单组件信息:默认人读摘要,--json 输出原始 JSON ***/ function cmdInfo(name, args = []) { const { packages } = loadIndex(); const asJson = args.includes('--json'); const pkg = packages.find((x) => x.name === name || x.name === `@ui-arch/${name}`); if (pkg) { if (asJson) return console.log(JSON.stringify(pkg, null, 2)); return console.log( `${pkg.name} v${pkg.version} [${pkg.type}] ${pkg.description}\n(--json 看完整清单;按组件查:ua info <组件id>)`, ); } const hit = collectComponents(packages).find((x) => x.comp.id === name); if (!hit) { const near = nearIds(name); console.error(`not found: ${name}(包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`); return process.exit(1); } if (asJson) return console.log(JSON.stringify({ package: hit.pkg.name, ...hit.comp, recipes: recipesFor(hit.comp.id) }, null, 2)); const related = recipesFor(hit.comp.id); const recipeTail = related.length ? `\n\n出现在配方:${related.map((r) => `${r.id}(${r.title})`).join('、')}(ua mcp 的 get_recipe 可取骨架)` : ''; if (args.includes('--md')) { // Markdown 片段:贴进文档 / issue / 回复时不用手抄参数表;配方清单压成一行尾注 return console.log(formatComponentMd(hit.pkg, hit.comp) + recipeTail); } console.log(formatComponent(hit.pkg, hit.comp) + recipeTail); } // ---- 静态文档站生成 ---- /*** HTML 转义:& < > ***/ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } /*** 渲染人类文档站:注册表 + 逐组件 props/步骤 + 现场演示 iframe + 令牌,数据内嵌支持搜索 ***/ function renderDocs(index, tokens) { const data = JSON.stringify(index.packages).replace(/ @ui-arch 组件库文档

@ui-arch私人组件库

`; } /*** 读取组合配方(与 CLI 同目录的 recipes.md),缺失时返回空串 ***/ function loadRecipes() { return fs.existsSync(RECIPES_FILE) ? fs.readFileSync(RECIPES_FILE, 'utf8').trim() : ''; } /*** 把 recipes.md 拆成结构化配方:每个「## 配方 N:标题」一段,切出首个代码块与要点行 ***/ function parseRecipes() { const text = loadRecipes().replace(/\r\n?/g, '\n'); // 归一化行尾:CRLF 的 \r 会让 `.+\$` 与标题目录匹配整体失效 if (!text) return []; const out = []; const chunks = text.split(/^## /m).slice(1); // 首段是文件标题与引言,无编号直接跳过 for (const chunk of chunks) { const [headLine, ...rest] = chunk.split('\n'); const head = headLine.match(/^配方\s*(\d+)\s*[::]\s*(.+)$/); // 只认编号配方,「通用约定」等说明段不算 if (!head) continue; const body = rest.join('\n').trim(); const code = body.match(/```[a-z]*\n([\s\S]*?)```/); // 首个代码块即最小可运行骨架 const notes = body .split('\n') .filter((line) => /^[-*]\s+/.test(line)) .map((line) => line.replace(/^[-*]\s+/, '').trim()); out.push({ id: head[1], title: head[2].trim(), code: code ? code[1].trim() : '', notes, text: body }); } return out; } /*** 从配方正文里认出用到的组件 id(词边界匹配,避免 toast 命中 toastError),CLI 与 MCP 共用一份 ***/ function recipeComps(r) { const ids = collectComponents().map((x) => x.comp.id); const body = `${r.code}\n${r.text}`; // 代码块 + 正文一起扫:要点里也会点名组件 return ids.filter((id) => new RegExp('\\b' + id + '\\b').test(body)).sort(); } /*** 列出组合配方(编号 / 标题 / 用到的组件),或按编号打印单条配方全文;MCP 没注册时也能查配方 ***/ function cmdRecipes(args = []) { const id = args.find((a) => !a.startsWith('--')); const list = parseRecipes(); if (!list.length) return console.error('recipes.md 里没有配方'); const asJson = args.includes('--json'); if (id) { const hit = list.find((r) => r.id === id); if (!hit) { console.error(`recipe not found: ${id}(可用 ${list.map((r) => r.id).join(' / ')})`); return process.exit(1); } if (asJson) return console.log(JSON.stringify({ id: hit.id, title: hit.title, components: recipeComps(hit), code: hit.code, notes: hit.notes }, null, 2)); return console.log(`# 配方 ${hit.id}:${hit.title} 用到的组件:${recipeComps(hit).join(' / ')} ${hit.text}`); } if (asJson) return console.log(JSON.stringify(list.map((r) => ({ id: r.id, title: r.title, components: recipeComps(r) })), null, 2)); for (const r of list) console.log(`${String(r.id).padEnd(4)} ${r.title} [${recipeComps(r).join(' / ')}]`); console.log(` 共 ${list.length} 条配方;看骨架与要点:ua recipes <编号>(MCP 里对应 get_recipe)`); } /*** 渲染 Agent 文档(llms-full.txt):无图形纯文本,逐组件参数 / 回调 / 步骤 / 约定全量收录 ***/ function renderAgentDocs(index, tokens) { const lines = [ '# @ui-arch 组件库 Agent 文档(无图形版)', '', `> 自动生成于 ${index.generatedAt},数据源:各包 ua.manifest.json,勿手改。人类版文档站:apps/docs/index.html。`, '> 组件 = 普通函数,传 props 返回 DOM;状态用 signal 双向绑定:读 signal() 写 signal(v)。', '', '## 快速上手', '', ]; for (const p of index.packages) { if (!p.docs?.steps?.length) continue; lines.push(`### ${p.name}`, ''); p.docs.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`)); lines.push(''); } const order = { 'skin-dom': 0, 'headless-core': 1, 'design-tokens': 2 }; for (const p of [...index.packages].sort((a, b) => (order[a.type] ?? 9) - (order[b.type] ?? 9))) { lines.push(`## ${p.name}(${p.type})v${p.version}`, ''); lines.push((p.docs?.intro ?? p.description) + '', ''); for (const c of p.docs?.components ?? []) { lines.push(`### ${c.id} — ${c.title ?? ''}`, ''); if (c.props?.length) { lines.push('参数:', ''); for (const prop of c.props) { const marks = [prop.r ? '必填' : '', prop.k ? '回调' : '', prop.df ? `默认 ${prop.df}` : ''].filter(Boolean).join(','); lines.push(`- ${prop.n}${marks ? `(${marks})` : ''}:${prop.t}${prop.d ? ` —— ${prop.d}` : ''}`); } lines.push(''); } if (c.example) lines.push('示例:', '', '```', c.example, '```', ''); if (c.steps?.length) { lines.push('手把手:', ''); c.steps.forEach((s, i) => lines.push(`${i + 1}. ${s}`)); lines.push(''); } if (c.demo) lines.push(`现场演示:apps/docs/demo.html#${c.demo}`, ''); } if (p.api?.exports?.length) { lines.push('API 速览:', ''); for (const e of p.api.exports) lines.push(`- ${e}`); lines.push(''); } if (p.usage) lines.push('一行用法:', '', '```', p.usage, '```', ''); if (p.conventions?.length) { lines.push('约定:', ''); for (const cv of p.conventions) lines.push(`- ${cv}`); lines.push(''); } lines.push('---', ''); } const tokenList = loadTokenList(); // 令牌唯一来源:tokens.css(含字号、间距复合值) if (tokenList.length) { lines.push('## 设计令牌(--ua-*)', ''); for (const t of tokenList) lines.push(`- ${t.cssVar}: ${t.value}${t.dark && t.dark !== t.value ? `(暗色 ${t.dark})` : ''}`); lines.push('', '皮肤包只允许消费这些变量:写死 #hex 会被 verify 拦截,拼错变量名会被令牌引用校验拦截。', ''); } const recipes = loadRecipes(); // 组合配方:跨组件的拼装指导,清单参数表表达不了 if (recipes) lines.push('', recipes, ''); return lines.join('\n'); } /*** 生成双轨文档:人类版 apps/docs/index.html(含演示 iframe)+ Agent 版 llms-full.txt ***/ function cmdDocs() { if (!fs.existsSync(INDEX_FILE)) cmdIndex(); const index = loadIndex(); const tokensPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json'); const tokens = fs.existsSync(tokensPath) ? JSON.parse(fs.readFileSync(tokensPath, 'utf8')) : null; const outDir = path.join(ROOT, 'apps', 'docs'); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, 'index.html'), renderDocs(index, tokens)); const demoSrc = path.join(ROOT, 'apps', 'demo', 'dist', 'demo.html'); // 演示页拷入文档站供 iframe if (fs.existsSync(demoSrc)) fs.copyFileSync(demoSrc, path.join(outDir, 'demo.html')); // llms-full 头部时间戳仅生成时刻之差视为无变化(与 index 幂等化同方案) const normTs = (s) => s.replace(/自动生成于 [^,]*/, '自动生成于 X'); writeIfChanged(LLMS_FULL, renderAgentDocs(index, tokens), normTs); console.log( `docs generated -> apps/docs/index.html + demo.html, llms-full.txt (${index.packages.length} packages)`, ); } // ---- 令牌查询:CLI 与 MCP 共用同一份拍平结果 ---- /*** 读取 tokens.css 拍平为令牌清单(:root 为基准值,[data-theme='dark'] 覆盖为暗色值;名字即 ui.css 实际变量名) ***/ function loadTokenList() { const p = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css'); if (!fs.existsSync(p)) return []; const src = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'); const grab = (block) => { const out = new Map(); // cssVar -> 值 for (const m of block.matchAll(/(--ua-[\w-]+)\s*:\s*([^;]+);/g)) out.set(m[1], m[2].trim()); return out; }; const rootBlock = (src.match(/:root\s*\{([\s\S]*?)\}/) ?? [, ''])[1]; // 亮色基准 const darkBlock = (src.match(/\[data-theme='dark'\]\s*\{([\s\S]*?)\}/) ?? [, ''])[1]; // 暗色覆盖 const dark = grab(darkBlock); return [...grab(rootBlock).entries()].map(([cssVar, value]) => dark.has(cssVar) ? { cssVar, value, dark: dark.get(cssVar) } : { cssVar, value }, ); } /*** ua tokens:打印令牌清单;--json 输出结构化数组供脚本消费 ***/ function cmdTokens(args) { const list = loadTokenList(); if (!list.length) { console.error('未找到 packages/design/tokens/tokens.json'); process.exit(1); } const keyword = args.find((a) => !a.startsWith('--'))?.toLowerCase(); // 带名字参数时按子串过滤,写样式时不必翻全表 const picked = keyword ? list.filter((t) => t.cssVar.toLowerCase().includes(keyword)) : list; if (keyword && !picked.length) { console.error(`no token match: ${keyword}(共 ${list.length} 个令牌,ua tokens 看全表)`); process.exit(1); } if (args.includes('--unused')) { // 反向视图:令牌定义了但没有任何组件 / 样式引用它 —— 令牌面越铺越大时靠这个收口, // 阈值口径只看皮肤样式表(ui.css 自身定义的自设变量也算消费),演示页与文档站不属于组件消费面。 // 消费面 = 皮肤样式表 + 令牌自身的派生引用(--ua-shadow-highlight 引用 --ua-color-highlight 也算消费) const sources = [path.join(ROOT, 'packages', 'skin', 'ui', 'ui.css'), path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css')]; const used = new Set(sources .filter((file) => fs.existsSync(file)) .flatMap((file) => fs.readFileSync(file, 'utf8').match(/var\(\s*(--[\w-]+)/g) ?? []) .map((m) => m.replace(/var\(\s*/, ''))); const dead = list.filter((t) => !used.has(t.cssVar)); if (args.includes('--json')) { console.log(JSON.stringify({ total: list.length, unused: dead.map((t) => t.cssVar) }, null, 2)); } else { for (const t of dead) console.log(`${t.cssVar} ${t.value}`); console.log(`共 ${list.length} 个令牌,其中 ${dead.length} 个没被皮肤包引用${dead.length ? '(确认是否还需要,删的时候亮暗与 tokens.json 一起改)' : ''}`); } return; } if (args.includes('--json')) { console.log(JSON.stringify(picked, null, 2)); return; } for (const t of picked) console.log(`${t.cssVar.padEnd(26)} ${t.value}${t.dark && t.dark !== t.value ? ' / 暗色 ' + t.dark : t.cssVar.startsWith('--ua-color-') && !t.dark ? ' / 缺暗色' : ''}`); console.log(`共 ${picked.length} 个令牌${keyword ? `(匹配 ${keyword})` : ''}`); } // ---- 内置 stdio MCP server(JSON-RPC 2.0,按行分帧) ---- /*** 启动 MCP server:向 Agent 暴露 list / get / search / tokens / recipes 六类查询工具 ***/ function cmdMcp() { if (!fs.existsSync(INDEX_FILE)) cmdIndex(); const { packages } = loadIndex(); const tools = [ { name: 'list_components', description: 'List all packages/components in the @ui-arch private library', inputSchema: { type: 'object', properties: {} }, }, { name: 'get_component', description: 'Get one package by name, or one component by its id; format=md returns a paste-ready Markdown block instead of raw JSON', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'component id (e.g. inputTag) or package name (e.g. @ui-arch/table-core)' }, format: { type: 'string', enum: ['json', 'md'], description: 'json (default) returns the manifest entry; md returns a paste-ready Markdown block (title + props table + usage + example)' }, }, required: ['name'], }, }, { name: 'search_components', description: 'Search by keyword: returns matched packages, matched components (id/title/prop names/prop descriptions/steps) and matched recipes (title/components/notes)', inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'], }, }, { name: 'get_tokens', description: 'Get the --ua-* design token list (cssVar / value / dark), so styles never hardcode colors or spacing', inputSchema: { type: 'object', properties: {} }, }, { name: 'list_recipes', description: 'List multi-component recipes (how to compose components together) with id/title/components', inputSchema: { type: 'object', properties: {} }, }, { name: 'get_recipe', description: 'Get one recipe by id: full markdown body, the minimal code skeleton and the components it uses', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'recipe id, e.g. 3(对应「配方 3」)' } }, required: ['id'], }, }, ]; const recipes = parseRecipes(); // 配方清单:MCP 侧只读一次 const compIds = collectComponents(packages).map((x) => x.comp.id); // 组件全集:把配方里的组件名回指到注册表 if (tools.map((t) => t.name).join() !== MCP_TOOL_NAMES.join()) { throw new Error(`MCP 工具注册表与 MCP_TOOL_NAMES 不一致:${tools.map((t) => t.name).join(',')}`); // 常量是文档校验的基准,先自证 } const recipeComponents = (r) => recipeComps(r).filter((id) => compIds.includes(id)); // 与 CLI recipes 共用同一抽取逻辑 const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n'); /*** 执行 MCP 工具调用 ***/ function callTool(name, args) { if (name === 'list_components') { return packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description })); } if (name === 'get_component') { const p = packages.find((x) => x.name === args.name || x.name === `@ui-arch/${args.name}`); if (p) return p; const hit = collectComponents(packages).find((x) => x.comp.id === args.name); // 退化到组件级查询 if (!hit) throw new Error(`not found: ${args.name}`); if (args.format === 'md') { // 与 ua info --md 共用同一渲染:Agent 拿到就能直接贴进回复 / 文档,不必自己排参数表 const related = recipesFor(hit.comp.id); const tail = related.length ? ` 出现在配方:${related.map((r) => `${r.id}(${r.title})`).join('、')}` : ''; return formatComponentMd(hit.pkg, hit.comp) + tail; // 直接给字符串:上层原样作为 text 返回 } return { package: hit.pkg.name, ...hit.comp, recipes: recipesFor(hit.comp.id) }; } if (name === 'search_components') { const q = String(args.query ?? '').toLowerCase().trim(); if (!q) throw new Error('query 不能为空'); const pkgs = packages .filter((p) => [p.name, p.description, p.type, ...(p.tags ?? []), ...(p.api?.exports ?? [])] .join(' ') .toLowerCase() .includes(q), ) .map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description })); const comps = collectComponents(packages) // 组件级命中:包级 exports 只给出包名,Agent 还得再查一次 .filter(({ comp }) => searchFields(comp).some((f) => f.text.includes(q))) // 与 CLI search 同一份字段表 .map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title })); const recs = parseRecipes() // 配方域:跨组件诉求(怎么拼)的直达载体,与 CLI search 同一套三层字段 .map((r) => ({ r, m: recipeMatch([q], r) })) .filter((x) => x.m) .map(({ r, m }) => ({ id: r.id, title: r.title, components: recipeComps(r), matched: { field: m.label, keyword: m.keyword } })); if (!pkgs.length && !comps.length && !recs.length) throw new Error(`no match: ${args.query}`); return { packages: pkgs, components: comps, recipes: recs }; } if (name === 'get_tokens') { const list = loadTokenList(); if (!list.length) throw new Error('tokens.json not found'); return list; } if (name === 'list_recipes') { return recipes.map((r) => ({ id: r.id, title: r.title, components: recipeComponents(r) })); } if (name === 'get_recipe') { const hit = recipes.find((r) => r.id === String(args.id ?? '').trim()); if (!hit) throw new Error(`recipe not found: ${args.id}(可用 ${recipes.map((r) => r.id).join('/')})`); return { id: hit.id, title: hit.title, components: recipeComponents(hit), code: hit.code, notes: hit.notes, markdown: hit.text }; } throw new Error(`unknown tool: ${name}`); } /*** 处理单条 JSON-RPC 消息;通知类消息不回包 ***/ function handle(msg) { const { id, method, params } = msg; if (method === 'initialize') { send({ jsonrpc: '2.0', id, result: { protocolVersion: params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'ui-arch-registry', version: '0.1.0' }, }, }); return; } if (method === 'tools/list') { send({ jsonrpc: '2.0', id, result: { tools } }); return; } if (method === 'tools/call') { try { const result = callTool(params?.name, params?.arguments ?? {}); send({ jsonrpc: '2.0', id, // 字符串结果原样返回:format=md 拿到的应是可读 Markdown,而不是被 JSON 转义成一行的 result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] }, }); } catch (err) { send({ jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: String(err.message) }] }, }); } return; } if (id != null) { send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); } } let buf = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { buf += chunk; let idx; while ((idx = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, idx).trim(); buf = buf.slice(idx + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { continue; } handle(msg); } }); } // ---- 风格检查(CODING.md 的机器执行版) ---- /*** 递归收集仓库源码文件(packages + apps),跳过产物与工具目录 ***/ function collectSourceFiles() { const exts = new Set(['.ts', '.mts', '.cts', '.mjs', '.js']); const skip = new Set(['node_modules', 'dist', '.ua', '.git', '.workbuddy']); const out = []; const walk = (dir) => { for (const name of fs.readdirSync(dir)) { if (skip.has(name)) continue; const full = path.join(dir, name); const st = fs.statSync(full); if (st.isDirectory()) walk(full); else if (exts.has(path.extname(name))) out.push(full); } }; walk(ROOT); return out; } /*** 把字符串 / 模板字面量整体替换为等长空格,便于按位置定位真实注释(避开 URL、正则里的 //) ***/ function blankStrings(line) { return line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, (m) => ' '.repeat(m.length)); } /*** 检查单文件:导出缺注释块、接口字段缺行尾注释、行注释非中文 ***/ function styleCheckFile(file, src) { const issues = []; const lines = src.split(/\r?\n/); let iface = null; // 当前追踪中的接口名 let depth = 0; // 接口体花括号深度 for (let i = 0; i < lines.length; i++) { const raw = lines[i]; const line = raw.trim(); if (iface) { for (const ch of raw) { // 花括号计数进出接口体 if (ch === '{') depth++; else if (ch === '}') depth--; } if (/^[A-Za-z_$][\w$]*\??:/.test(line) && !line.includes('//')) { issues.push(`${file}:${i + 1} 接口 ${iface} 字段缺少行尾注释`); } if (depth <= 0) iface = null; } else { const im = raw.match(/\bexport\s+interface\s+(\w+)/); if (im) { depth = (raw.match(/\{/g) || []).length - (raw.match(/\}/g) || []).length; iface = im[1]; } } const decl = line.match(/^export\s+(?:async\s+)?(?:function|interface|type|class)\s+([A-Za-z_$][\w$]*)/); if (decl) { let j = i - 1; while (j >= 0 && lines[j].trim() === '') j--; // 找上一个非空行 if (j < 0 || !lines[j].includes('/***')) { // 上一行是注释续行(以 ***/ 结尾却没有 /***)说明写成了多行,给出可直接照做的修法 const cont = j >= 0 && lines[j].includes('***/') && !lines[j].includes('/***'); const hint = cont ? '(注释被拆成多行:压成单行 /*** 作用 ***/ 并紧贴导出上一行)' : ''; issues.push(`${file}:${i + 1} 导出 ${decl[1]} 缺少 /*** 作用 ***/ 注释${hint}`); } } const hash = blankStrings(raw).indexOf('//'); // 注释定位:先剥离字面量,'https://' 不算注释 if (hash >= 0) { const text = raw.slice(hash + 2); const isPragma = /^[\s*/]*(eslint|@ts-|prettier-ignore|istanbul|c8|node:)/i.test(text); if (!isPragma && /[A-Za-z]{2,}/.test(text) && !/[\u4e00-\u9fff]/.test(text)) { issues.push(`${file}:${i + 1} 行注释需使用中文`); } } } return issues; } // 单文件行数上限:CODING.md「单文件尽量 < 200 行」,只约束包内实现文件 const MAX_LINES = 200; // 不受行数限制的汇总文件:类型集中文件与导出入口天然会长 const SIZE_EXEMPT = new Set(['types.ts', 'index.ts']); /*** 行数检查:只查分层包 src 目录下的实现文件,超出上限即违规 ***/ function sizeCheckFile(file) { const rel = path.relative(ROOT, file).replace(/\\/g, '/'); if (!/^packages\/[^/]+\/[^/]+\/src\//.test(rel)) return []; // 只约束分层包源码 if (SIZE_EXEMPT.has(path.basename(file))) return []; // 类型 / 导出汇总豁免 const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).length; return lines > MAX_LINES ? [`${rel} 共 ${lines} 行,超过单文件 ${MAX_LINES} 行上限(CODING.md)`] : []; } /*** 全仓风格检查:注释规范 + 单文件行数,有违规退出码 1,全绿输出 OK ***/ function cmdStyle() { const files = collectSourceFiles(); const issues = []; for (const f of files) { issues.push(...styleCheckFile(f, fs.readFileSync(f, 'utf8'))); issues.push(...sizeCheckFile(f)); } if (issues.length) { for (const it of issues) console.error('style: ' + it); console.error(`${issues.length} violation(s)`); process.exit(1); } console.log(`style OK (${files.length} files, 实现文件 ≤ ${MAX_LINES} 行)`); } /*** 皮肤包硬编码颜色检查:#hex 字面量一律禁止,颜色必须走 --ua-* 令牌(rgba 仅限阴影/遮罩的透明度叠加) ***/ function checkSkinCss(pkg) { const issues = []; const dir = path.join(ROOT, pkg.dir); if (!fs.existsSync(dir)) return issues; for (const name of fs.readdirSync(dir)) { if (!name.endsWith('.css')) continue; // 只查样式表 const file = path.join(dir, name); const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); lines.forEach((line, i) => { if (/#[0-9a-fA-F]{3,8}\b/.test(line)) issues.push(`${pkg.dir}/${name}:${i + 1} 皮肤包禁止硬编码颜色,改用 --ua-* 令牌`); }); } return issues; } /*** 滚动条覆盖校验:凡是 overflow: auto|scroll 的 .ua-* 容器,都必须出现在「scrollbar-width」那条统一规则里 ***/ function checkScrollCoverage(pkg) { const issues = []; const dir = path.join(ROOT, pkg.dir); if (!fs.existsSync(dir)) return issues; for (const name of fs.readdirSync(dir)) { if (!name.endsWith('.css')) continue; // 只查样式表 const css = fs.readFileSync(path.join(dir, name), 'utf8').replace(/\r\n/g, '\n').replace(/\/\*[\s\S]*?\*\//g, ''); const rules = []; // 括号配平扫出的「选择器 + 规则体」;正则切不了嵌套(@media 里有花括号) let i = 0; let sel = ''; while (i < css.length) { if (css[i] === '{') { const start = i + 1; let depth = 1; i += 1; while (i < css.length && depth > 0) { if (css[i] === '{') depth += 1; else if (css[i] === '}') depth -= 1; i += 1; } rules.push({ sel: sel.trim(), body: css.slice(start, i - 1) }); sel = ''; continue; } if (css[i] === '}') { sel = ''; i += 1; continue; } // 顶层多余的右括号:重置选择器 sel += css[i]; i += 1; } const containers = new Set(); // 滚得动的单类容器 for (const rule of rules) { if (!/overflow(-[xy])?:\s*(auto|scroll)/.test(rule.body)) continue; for (const part of rule.sel.split(',')) { const hit = part.trim().match(/^\.(ua-[a-z0-9-]+)$/); if (hit) containers.add(hit[1]); } } if (!containers.size) continue; const group = rules.find((rule) => rule.body.includes('scrollbar-width')); // 细条 + 令牌拇指那条统一规则 if (!group) { issues.push(`${pkg.dir}/${name}: 未找到滚动条统一规则(scrollbar-width),新增滚动容器会退回系统原生粗条`); continue; } const covered = new Set([...group.sel.matchAll(/\.(ua-[a-z0-9-]+)/g)].map((m) => m[1])); for (const cls of containers) { if (covered.has(cls)) continue; issues.push(`${pkg.dir}/${name}: 滚动容器 .${cls} 未纳入滚动条统一规则(暗色下会露出原生亮条)`); } } return issues; } /*** 重复关键帧校验:两份 @keyframes 的帧体完全一致 = 复制粘贴的动效,语义上只应存在一份(名字不同也算) ***/ function checkDuplicateKeyframes(pkg) { const issues = []; const dir = path.join(ROOT, pkg.dir); if (!fs.existsSync(dir)) return issues; for (const name of fs.readdirSync(dir)) { if (!name.endsWith('.css')) continue; // 只查样式表 const file = path.join(dir, name); const src = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); const seen = new Map(); // 帧体(去空白归一)-> 首个声明它的动画名 for (const hit of src.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) { const [, anim, body] = hit; const norm = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim(); // 注释与空白不参与比对 const first = seen.get(norm); if (first) { issues.push(`${pkg.dir}/${name}: @keyframes ${anim} 与 ${first} 帧体完全相同(复制粘贴的动效,合并成一个再复用)`); continue; } seen.set(norm, anim); } } return issues; } /*** 皮肤包令牌引用校验:var(--ua-*) 必须是 tokens.css 令牌、同文件自定义变量,或组件脚本里 setProperty 的动态变量(防拼错名字静默失效) ***/ function checkTokenRefs(pkg, allowed) { const issues = []; const dir = path.join(ROOT, pkg.dir); if (!fs.existsSync(dir)) return issues; const local = new Set(); // 组件自身定义/写入的 --ua-*:CSS 定义 + TS 里 setProperty 的动态变量 const srcDir = path.join(dir, 'src'); if (fs.existsSync(srcDir)) { for (const f of fs.readdirSync(srcDir)) { if (!f.endsWith('.ts')) continue; for (const m of fs.readFileSync(path.join(srcDir, f), 'utf8').matchAll(/--ua-[\w-]+/g)) local.add(m[0]); } } for (const name of fs.readdirSync(dir)) { if (!name.endsWith('.css')) continue; // 只查样式表引用 const src = fs.readFileSync(path.join(dir, name), 'utf8'); for (const m of src.matchAll(/(--ua-[\w-]+)\s*:/g)) local.add(m[1]); // 样式表内自定义变量 for (const hit of src.matchAll(/var\(\s*(--ua-[\w-]+)/g)) { const ref = hit[1]; if (!allowed.has(ref) && !local.has(ref)) issues.push(`${pkg.dir}/${name}: 引用未定义令牌 ${ref}`); } } return issues; } /*** README 组件区块校验:marker 之间的 id 集合必须与全部清单的 docs.components 完全一致(防文档漂移) ***/ function checkReadme(pkgs) { const issues = []; const file = path.join(ROOT, 'README.md'); if (!fs.existsSync(file)) return issues; // 卫语句:没有 README 就不校验 const block = fs.readFileSync(file, 'utf8').match(/([\s\S]*?)/); if (!block) { issues.push('README.md 缺少组件区块标记( … )'); return issues; } const blockIds = block[1].split('·').map((s) => s.trim()).filter(Boolean); // 保留原始顺序,供重复检测 const listed = new Set(blockIds); const dupInBlock = blockIds.filter((id, i) => blockIds.indexOf(id) !== i); // 同一 id 出现多次:文档漂移 for (const id of new Set(dupInBlock)) issues.push(`README.md 组件区块重复列出 ${id}`); const actual = new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))); for (const id of actual) if (!listed.has(id)) issues.push(`README.md 组件区块缺少 ${id}`); for (const id of listed) if (!actual.has(id)) issues.push(`README.md 组件区块多出 ${id}(清单里没有)`); // 分组行同一份清单的另一种表述:漏写/多写同样算文档漂移 const groupLine = fs.readFileSync(file, 'utf8').split(/\r?\n/).find((line) => line.startsWith('分组:')); if (!groupLine) { issues.push('README.md 缺少「分组:」行(组件分类导航)'); return issues; } const grouped = new Set(groupLine.match(/[a-zA-Z][A-Za-z0-9]*/g) ?? []); const alias = new Set(['formItem']); // formItem 与 form 共用一条文档,分组行里会单独出现 for (const id of actual) { const hits = groupLine.match(new RegExp(`(? 1) issues.push(`README.md 分组行重复列出 ${id}(${hits.length} 次)`); } for (const id of grouped) if (!actual.has(id) && !alias.has(id)) issues.push(`README.md 分组行多出 ${id}(清单里没有)`); return issues; } /*** 组件数漂移校验:AGENTS.md / ARCHITECTURE.md 中的「N 个组件」必须与 skin-dom 清单实际总数一致 ***/ // 组件计数的三种写法:「N 个组件」「N 个 DOM 组件」「共 N 个」 const COUNT_RE = /(\d+)\s*个\s*(?:DOM\s*)?组件|共\s*(\d+)\s*个/g; /*** 逐行扫文档里的组件计数,返回 { line, value, text } 便于校验与改写共用 ***/ function scanCountLines(text) { const hits = []; text.split(/\r?\n/).forEach((line, i) => { for (const m of line.matchAll(COUNT_RE)) hits.push({ line: i + 1, value: Number(m[1] ?? m[2]), text: line }); }); return hits; } function checkCountDocs(actual) { const issues = []; for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'README.md']) { const file = path.join(ROOT, name); if (!fs.existsSync(file)) continue; // 卫语句:文档缺失不校验 for (const hit of scanCountLines(fs.readFileSync(file, 'utf8'))) { if (hit.value !== actual) issues.push(`${name}:${hit.line} 组件数「${hit.value}」与清单实际 ${actual} 不一致,跑 ua sync-counts 自动同步`); } } return issues; } /*** 组件计数同步:把三份文档里的「N 个组件 / 共 N 个」一律改成清单实际数量(幂等,无变化不写盘) ***/ function cmdSyncCounts() { const actual = collectComponents(collectManifests()).length; let changed = 0; for (const name of ['AGENTS.md', 'ARCHITECTURE.md', 'README.md']) { const file = path.join(ROOT, name); if (!fs.existsSync(file)) continue; // 卫语句:文档缺失跳过 const before = fs.readFileSync(file, 'utf8'); const after = before.replace(COUNT_RE, (m, a, b) => (a ? m.replace(a, String(actual)) : m.replace(b, String(actual)))); if (after === before) continue; fs.writeFileSync(file, after, 'utf8'); changed += 1; } console.log(`sync-counts: 清单实际 ${actual} 个组件,改写 ${changed} 个文件`); } /*** 令牌双源一致性:tokens.json 的 color 键(camelCase)与 tokens.css 的 --ua-color-* 必须同名同值(亮/暗两套都比) ***/ function checkTokenParity(issues) { const jsonPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json'); const cssPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css'); if (!fs.existsSync(jsonPath) || !fs.existsSync(cssPath)) return; // 卫语句:任一缺失交给其它校验兜底 const tokens = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); if (!tokens.color) return; // 卫语句:没有颜色分组(旧版本令牌表)不参与本校验 const src = fs.readFileSync(cssPath, 'utf8').replace(/\r\n/g, '\n'); const grab = (block) => { const out = new Map(); // cssVar -> 值 for (const m of block.matchAll(/(--ua-[\w-]+)\s*:\s*([^;]+);/g)) out.set(m[1], m[2].trim()); return out; }; const root = grab((src.match(/:root\s*\{([\s\S]*?)\}/) ?? [, ''])[1]); const dark = grab((src.match(/\[data-theme='dark'\]\s*\{([\s\S]*?)\}/) ?? [, ''])[1]); const kebab = (key) => key.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/([a-zA-Z])(\d)/g, '$1-$2').toLowerCase(); const jsonVars = new Set(); // JSON 声明的颜色变量名,用于反查 CSS 里多出来的 for (const [key, pair] of Object.entries(tokens.color)) { const name = `--ua-color-${kebab(key)}`; jsonVars.add(name); if (!root.has(name)) { issues.push(`tokens: tokens.json 的 color.${key} 在 tokens.css :root 里没有 ${name}(文档色板会列出不存在的令牌)`); continue; // 名字都不存在,值比对没意义 } if (root.get(name) !== pair.light) issues.push(`tokens: ${name} 亮色值不一致(tokens.json "${pair.light}" vs tokens.css "${root.get(name)}")`); if (dark.has(name) && dark.get(name) !== pair.dark) issues.push(`tokens: ${name} 暗色值不一致(tokens.json "${pair.dark}" vs tokens.css "${dark.get(name)}")`); } for (const name of root.keys()) { if (!name.startsWith('--ua-color-')) continue; // 卫语句:只对颜色分组做双向比对 if (!jsonVars.has(name)) issues.push(`tokens: tokens.css 的 ${name} 未在 tokens.json 的 color 里登记(文档色板与 ua tokens 会漏项)`); } } /*** 暗色令牌齐全校验:每个 --ua-color-* 都得在 [data-theme='dark'] 里有覆盖值,反向也查暗色块里的变量先在 :root 定义过(防改名漏改) ***/ function checkDarkTokens(issues) { const p = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css'); if (!fs.existsSync(p)) return; // 卫语句:令牌文件缺失交给其它校验兜底 const src = fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'); const names = (block) => [...block.matchAll(/(--ua-[\w-]+)\s*:/g)].map((m) => m[1]); // 块内变量名,按出现顺序 const rootNames = names((src.match(/:root\s*\{([\s\S]*?)\}/) ?? [, ''])[1]); const darkNames = names((src.match(/\[data-theme='dark'\]\s*\{([\s\S]*?)\}/) ?? [, ''])[1]); const darkSet = new Set(darkNames); const rootSet = new Set(rootNames); for (const n of rootNames) { if (!n.startsWith('--ua-color-')) continue; // 卫语句:只有颜色需要暗色重定值,间距/字号/圆角通用 if (!darkSet.has(n)) issues.push(`tokens: 颜色令牌 ${n} 缺 [data-theme='dark'] 覆盖(暗色下会沿用亮色值)`); } for (const n of rootNames) { if (!/^--ua-(shadow|focus)/.test(n)) continue; // 卫语句:阴影与焦点环在暗底上必须重定值(黑投影要加黑、色环要补对比) if (!darkSet.has(n)) issues.push(`tokens: 令牌 ${n} 缺 [data-theme='dark'] 覆盖(暗底上沿用亮色值会丢层级/焦点对比)`); } for (const n of darkNames) if (!rootSet.has(n)) issues.push(`tokens: 暗色块里的 ${n} 未在 :root 定义(改名后忘了同步)`); } /*** llms-full.txt 同步校验:内联重渲染 Agent 文档(时间戳归一化)与落盘产物比对,防 manifest 改后忘跑 docs ***/ function checkLlmsFull(pkgs, issues) { if (!fs.existsSync(LLMS_FULL)) return; // 卫语句:产物缺失不算漂移(可能是新仓库) const tokensPath = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json'); const tokens = fs.existsSync(tokensPath) ? JSON.parse(fs.readFileSync(tokensPath, 'utf8')) : null; const norm = (s) => s.replace(/\r\n/g, '\n').replace(/自动生成于 [^,]*/, '自动生成于 X'); const expected = renderAgentDocs({ generatedAt: '', packages: pkgs }, tokens); if (norm(fs.readFileSync(LLMS_FULL, 'utf8')) !== norm(expected)) { issues.push('llms-full.txt 与清单不一致,请运行 npm run docs 重新生成'); } } /*** 生成 CHANGELOG.md:按提交类型前缀分组渲染 git 史(输出不含时间戳,可幂等比对);--check 只校验不写盘 ***/ function cmdChangelog(args) { const check = args.includes('--check'); const res = spawnSync('git', ['log', '-n', '400', '--pretty=format:%h\t%s'], { cwd: ROOT, encoding: 'utf8' }); if (res.status !== 0) { console.error('changelog: 读取 git log 失败(是否在 git 仓库内?)'); process.exit(1); } const rows = res.stdout.split(/\r?\n/).filter(Boolean).map((line) => { const [hash, ...rest] = line.split('\t'); // 第一段为短哈希,其余为标题(标题可能含制表符) return { hash, subject: rest.join('\t') }; }); const groups = [ { title: '迭代', test: /^iter\(/ }, // 自动化迭代提交 { title: '功能', test: /^feat[(:]/ }, // 新功能 { title: '修复', test: /^fix[(:]/ }, // 缺陷修复 { title: '文档', test: /^docs[(:]/ }, // 文档变更 { title: '维护', test: /^(chore|refactor|test|build|ci)[(:]/ }, // 杂项维护 ]; const buckets = new Map(groups.map((g) => [g.title, []])); buckets.set('其他', []); // 未匹配前缀的提交兜底 for (const row of rows) { const hit = groups.find((g) => g.test.test(row.subject)); buckets.get(hit ? hit.title : '其他').push(row); } const parts = ['# 变更日志', '', '> 由 `ua changelog` 依据 git 提交史生成,请勿手改;条目按提交时间倒序。', '']; for (const [title, list] of buckets) { if (!list.length) continue; // 空分组不渲染 parts.push(`## ${title}`, ''); for (const row of list) parts.push(`- \`${row.hash}\` ${row.subject}`); parts.push(''); } const next = parts.join('\n'); const file = path.join(ROOT, 'CHANGELOG.md'); const prev = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; const same = prev.replace(/\r\n/g, '\n') === next; if (check) { if (!same) { console.error('changelog: CHANGELOG.md 与提交史不一致,请运行 ua changelog 重新生成'); process.exit(1); } console.log('changelog OK (与提交史一致)'); return; } if (same) { console.log('changelog unchanged, skip'); return; } // 无变化不写盘,避免空提交 fs.writeFileSync(file, next); console.log(`changelog written -> CHANGELOG.md (${rows.length} commits)`); } /*** 交叉校验留痕与条目:done 条目必须有一条 done 留痕,留痕 id 必须存在于 backlog(防「标完成无记录 / 记录指向幽灵条目」) ***/ function checkIterations() { const issues = []; const backlogPath = path.join(ROOT, '.ua', 'backlog.json'); const logPath = path.join(ROOT, '.ua', 'iterations.log'); if (!fs.existsSync(backlogPath) || !fs.existsSync(logPath)) return issues; // 卫语句:产物缺失不校验 const items = JSON.parse(fs.readFileSync(backlogPath, 'utf8')).items ?? []; const ids = new Set(items.map((i) => i.id)); const done = new Set(); // 有 done 留痕的条目 id const seen = new Set(); // 条目 id 去重集合:复制粘贴条目忘了改 id 时会撞上 for (const item of items) { if (seen.has(item.id)) issues.push(`backlog: id ${item.id} 重复(多半是复制条目时忘了改 id)`); seen.add(item.id); } const seq = items.map((i) => (typeof i.id === 'string' && /^g\d+$/.test(i.id) ? Number(i.id.slice(1)) : NaN)); // g 编号序列 for (let k = 1; k < seq.length; k++) { if (Number.isNaN(seq[k]) || Number.isNaN(seq[k - 1])) continue; // 非 g 编号的条目不参与顺序校验 if (seq[k] <= seq[k - 1]) issues.push(`backlog: ${items[k].id} 排在 ${items[k - 1].id} 后面(编号必须严格递增,逆序一般是插错了位置)`); } let prevTs = ''; // 上一条留痕时间,用于单调性比对 for (const line of fs.readFileSync(logPath, 'utf8').split(/\r?\n/)) { if (!line.trim()) continue; // 空行跳过 let row; try { row = JSON.parse(line); } catch { continue; } // 行格式归 lint-iterations 管,这里不重复报 if (!ids.has(row.id)) issues.push(`iterations.log: 留痕 id ${row.id} 在 backlog 中不存在`); if (row.result === 'done') { if (done.has(row.id)) issues.push(`iterations.log: ${row.id} 有两条 done 留痕(一条迭代只该留一次痕迹)`); done.add(row.id); } const ts = String(row.ts ?? ''); if (!/\+08:00$/.test(ts)) issues.push(`iterations.log: ${row.id} 的 ts 缺少 +08:00 偏移(必须取 date 实测的本地时间)`); else if (prevTs && ts < prevTs) issues.push(`iterations.log: ${row.id} 的 ts 早于上一条(时间倒流,多半是手写或按 UTC 换算)`); else if (Date.parse(ts) > Date.now() + 300000) issues.push(`iterations.log: ${row.id} 的 ts 落在未来(超出 5 分钟容差)`); prevTs = ts; } for (const item of items) { if (item.status === 'done' && !done.has(item.id)) issues.push(`backlog: ${item.id} 已置 done 但 iterations.log 无 done 留痕`); } return issues; } /*** MCP 工具清单校验:README / AGENTS / CLI 清单必须逐个写出实际注册的工具名(新增工具漏改文档会命中) ***/ function checkMcpDocs() { const issues = []; const files = [ ['README.md', path.join(ROOT, 'README.md')], ['AGENTS.md', path.join(ROOT, 'AGENTS.md')], ['packages/tools/cli/ua.manifest.json', path.join(ROOT, 'packages', 'tools', 'cli', 'ua.manifest.json')], ]; for (const [label, file] of files) { if (!fs.existsSync(file)) { issues.push(`${label} 不存在:MCP 工具清单无处登记`); continue; } const text = fs.readFileSync(file, 'utf8'); const miss = MCP_TOOL_NAMES.filter((n) => !text.includes(n)); if (miss.length) issues.push(`${label} 的 MCP 工具清单缺少:${miss.join(' / ')}(改了 ua mcp 的工具就要同步这三处)`); } return issues; } /*** 配方结构校验:编号连续不重复、每条都有最小代码块与要点、通用约定段存在(MCP 与 llms-full 都按这个结构取用) ***/ function checkRecipes() { const issues = []; const text = loadRecipes(); if (!text) return ['recipes.md 缺失或为空:组合配方是 Agent 侧的主要拼装依据']; const list = parseRecipes(); if (!list.length) issues.push('recipes.md 里没有任何「## 配方 N:标题」段落,MCP 的 list_recipes 会返回空表'); list.forEach((r, i) => { if (r.id !== String(i + 1)) issues.push(`配方编号不连续:第 ${i + 1} 条是「配方 ${r.id}」,应为「配方 ${i + 1}」`); if (!r.code) issues.push(`配方 ${r.id}(${r.title})缺少 ts 代码块:Agent 拿不到最小骨架`); if (!r.notes.length) issues.push(`配方 ${r.id}(${r.title})缺少要点行(以 - 开头的说明)`); if (r.code && !recipeComps(r).length) { issues.push(`配方 ${r.id}(${r.title})认不出任何已注册组件:组件名可能拼错,MCP 的 list_recipes 会把它列成空组件表`); } }); if (!/^## 通用约定/m.test(text)) issues.push('recipes.md 缺少「## 通用约定」段:跨配方的共同约束没地方落'); return issues; } /*** 演示产物校验:打包后的 demo.html 必须含每个区块锚点,证明演示代码真的编进了产物(防「只改源码、产物过期」) ***/ function checkDemoBundle(anchors) { const issues = []; const file = path.join(ROOT, 'apps', 'demo', 'dist', 'demo.html'); if (!fs.existsSync(file)) return issues; // 卫语句:产物未构建时不判定(check 链已保证有 demo 步骤) const html = fs.readFileSync(file, 'utf8'); for (const a of anchors) { if (!html.includes(a)) issues.push(`demo 产物缺少区块锚点 ${a},请重跑 npm run demo`); } return issues; } /*** 文档字段漂移校验:props 名必须在实现源码里真实出现(改了参数名忘了改文档就会命中) ***/ function checkPropsImpl(pkg, comps) { const issues = []; const srcDir = path.join(ROOT, pkg.dir, 'src'); // 实现源码目录 if (!fs.existsSync(srcDir)) return issues; const sources = new Map(); // 文件名 → 源码文本 for (const name of fs.readdirSync(srcDir)) { if (name.endsWith('.ts')) sources.set(name, fs.readFileSync(path.join(srcDir, name), 'utf8')); } const findImpl = (id) => { const own = sources.get(`${id}.ts`); if (own !== undefined) return own; // 同名文件优先 const decl = new RegExp(`export function ${id}\\(`); // 兜底:按导出函数名反查文件 for (const text of sources.values()) if (decl.test(text)) return text; return null; }; for (const c of comps) { if (!c.id) continue; // 缺 id 由主循环报,这里跳过 const tag = `${pkg.name}#${c.id}`; const impl = findImpl(c.id); if (impl === null) { issues.push(`${tag}: 在 ${pkg.dir}/src 下找不到实现(既无同名文件,也没有 export function ${c.id})`); continue; } for (const p of c.props ?? []) { const name = String(p.n ?? ''); if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue; // 说明性条目(...children、带括号注记)不参与 if (!impl.includes(name)) issues.push(`${tag}: 文档声明了 props「${name}」,实现里找不到该名字(改名后文档未同步)`); } } return issues; } /*** 反向漂移校验:入参接口的每个字段都必须在文档 props 表登记(新加字段忘了写文档会命中) ***/ function checkPropsDoc(pkg, comps) { const issues = []; const typesPath = path.join(ROOT, pkg.dir, 'src', 'types.ts'); // 约定:类型集中在 src/types.ts if (!fs.existsSync(typesPath)) return issues; const src = fs.readFileSync(typesPath, 'utf8'); const ifaces = new Map(); // 接口基名(已去掉 Props 后缀)→ 字段名数组 for (const m of src.matchAll(/export interface (\w+)Props(?:<[^>]*>)?\s*\{([\s\S]*?)\n\}/g)) { const fields = []; for (const line of m[2].split(/\r?\n/)) { const hit = /^\s{2}(\w+)(\?|:)/.exec(line); // 缩进两格的字段声明 if (hit) fields.push(hit[1]); } ifaces.set(m[1], fields); } if (!ifaces.size) return issues; const pascal = (id) => id[0].toUpperCase() + id.slice(1); for (const c of comps) { if (!c.id || c.propsType === false) continue; // propsType: false 表示该组件没有入参接口 const tag = `${pkg.name}#${c.id}`; const base = String(c.propsType ?? '').replace(/Props$/, '') || pascal(c.id); // 清单可覆盖接口名 const fields = ifaces.get(base); if (!fields) { issues.push(`${tag}: 找不到入参接口 ${base}Props(命名不同请在清单用 propsType 指定,无入参写 false)`); continue; } const documented = new Set((c.props ?? []).map((p) => String(p.n ?? '').replace(/^([^)]*)/, '').replace(/^\.\.\./, '').trim())); // 去掉前缀注记与展开符 for (const f of fields) { if (!documented.has(f)) issues.push(`${tag}: 入参接口 ${base}Props 的字段「${f}」未在文档 props 表登记`); } } return issues; } /*** 视觉回归抽查清单校验:ITERATION.md 的清单必须十一个代表组件齐全、每条带合法维度标签、条目数达标 ***/ function checkVisualChecklist() { const DIMS = ['边框', '阴影', '过渡', '焦点环', '暗色']; // 五个必覆盖维度 const REQUIRED = ['button', 'textField', 'select', 'modal', 'dataTable', 'toast', 'layout', 'jsonViewer', 'diffView', 'codeBlock', 'signaturePad']; // 十一个代表组件:先六个高频,再加「容器 / 数据展示 / 表单录入」三类新组件的抽查位 const MIN_PER_GROUP = 3; // 每个组件至少三条可勾选要点 const MIN_TOTAL = REQUIRED.length * MIN_PER_GROUP; // 总条目下限由组件数推导:加抽查位不用再手改这个数 const file = path.join(ROOT, 'ITERATION.md'); if (!fs.existsSync(file)) return ['视觉回归抽查清单:找不到 ITERATION.md']; const lines = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n'); const start = lines.findIndex((l) => /^##\s+视觉回归抽查清单/.test(l)); if (start === -1) return ['视觉回归抽查清单:ITERATION.md 缺少《视觉回归抽查清单》章节']; const rest = lines.slice(start + 1); const stop = rest.findIndex((l) => /^##\s+/.test(l)); // 下一个二级标题即章节结束 const body = stop === -1 ? rest : rest.slice(0, stop); const issues = []; const groups = new Map(); // 组件 id → 该段的维度标签列表 let current = null; for (const line of body) { const head = line.match(/^###\s+(\S+)/); if (head) { current = head[1]; if (!groups.has(current)) groups.set(current, []); continue; } const item = line.match(/^-\s+\[[ x]\]\s+【([^】]+)】/); if (!item) continue; if (!current) { issues.push(`视觉回归抽查清单:条目「${line.trim().slice(0, 24)}」没有归到任何 ### 组件段下`); continue; } if (!DIMS.includes(item[1])) issues.push(`视觉回归抽查清单:${current} 的条目维度「${item[1]}」不在五类之内(${DIMS.join(' / ')})`); groups.get(current).push(item[1]); } for (const id of REQUIRED) { const items = groups.get(id); if (!items) issues.push(`视觉回归抽查清单:缺少 ### ${id} 段`); else if (items.length < MIN_PER_GROUP) issues.push(`视觉回归抽查清单:${id} 只有 ${items.length} 条要点,至少 ${MIN_PER_GROUP} 条`); } const total = [...groups.values()].reduce((sum, items) => sum + items.length, 0); if (total < MIN_TOTAL) issues.push(`视觉回归抽查清单:合计 ${total} 条要点,至少 ${MIN_TOTAL} 条`); const covered = new Set([...groups.values()].flat()); const missed = DIMS.filter((d) => !covered.has(d)); if (missed.length) issues.push(`视觉回归抽查清单:维度未覆盖 ${missed.join(' / ')}(五类都要有)`); return issues; } /*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/ function cmdVerify() { const issues = []; const pkgs = collectManifests(); // 一次收集,供组件校验与 README 区块校验共用 const demoPath = path.join(ROOT, 'apps', 'demo', 'main.ts'); // 演示区块定义在源码里(产物是打包后的 JS) const demoSource = fs.existsSync(demoPath) ? fs.readFileSync(demoPath, 'utf8') : ''; const anchors = new Set(demoSource.match(/sec-[a-z0-9-]+/g) ?? []); // 已定义的区块锚点 for (const raw of demoSource.match(/sec-[A-Za-z0-9-]+/g) ?? []) { if (/[A-Z]/.test(raw)) issues.push(`demo 锚点 ${raw} 含大写字母:锚点口径只认 sec-[a-z0-9-](大写会被锚点集合静默吞掉, manifests 登记后会误报「缺 section」)`); // 防大写锚点静默失踪(g234 实踩) } const tokenVars = new Set(loadTokenList().map((t) => t.cssVar)); // 令牌白名单:tokens.json 拍平结果 if (demoSource) issues.push(...checkDemoBundle(anchors)); // 产物与源码同步校验 if (!demoSource) console.log('verify: 未找到 apps/demo/main.ts,跳过 demo 锚点校验'); issues.push(...checkRecipes()); // 配方结构:编号连续 + 骨架 + 要点,保证 MCP 取得到 issues.push(...checkMcpDocs()); // MCP 工具清单:改注册表必须同步 README / AGENTS / CLI 清单 for (const pkg of pkgs) { const comps = pkg.docs?.components ?? []; // 清单已被 collectManifests 摊平 const exports = pkg.api?.exports ?? []; if (pkg.type === 'skin-dom') { issues.push(...checkSkinCss(pkg)); issues.push(...checkDuplicateKeyframes(pkg)); issues.push(...checkScrollCoverage(pkg)); issues.push(...checkTokenRefs(pkg, tokenVars)); issues.push(...checkPropsImpl(pkg, comps)); issues.push(...checkPropsDoc(pkg, comps)); } if (comps.length && !exports.length) issues.push(`${pkg.name}: docs.components 有 ${comps.length} 项但 api.exports 为空`); const seen = new Set(); // 组件 id 去重(同一清单内不允许重复) for (const c of comps) { const tag = `${pkg.name}#${c.id ?? '?'}`; if (!c.id || !c.title) issues.push(`${tag}: 缺少 id 或 title`); if (seen.has(c.id)) issues.push(`${tag}: id 重复`); seen.add(c.id); if (!c.example) issues.push(`${tag}: 缺少 example`); if ((c.steps ?? []).length < 3) issues.push(`${tag}: steps 少于 3 条`); if (!exports.some((e) => e.startsWith(`${c.id}(`) || e.startsWith(`${c.id}.`))) { issues.push(`${tag}: api.exports 中没有以「${c.id}(」或「${c.id}.」开头的条目`); } for (const prop of c.props ?? []) { if (!prop.n || !prop.t || !prop.d) issues.push(`${tag}: props 条目 ${prop.n ?? '?'} 缺 n/t/d`); } if (c.demo && demoSource && !anchors.has(c.demo)) { issues.push(`${tag}: demo 锚点 ${c.demo} 在 apps/demo/main.ts 中不存在(缺对应 section)`); } if (demoSource && !demoSource.includes(`${c.id}(`) && !demoSource.includes(`${c.id}.`)) { issues.push(`${tag}: apps/demo/main.ts 未实际调用 ${c.id}()(只有 section 锚点不算演示)`); } } } // 反向校验:演示里定义的每个 section 都得被某个组件认领,否则就是「孤儿演示」——区块加了、清单没登记 // 只认 section( 的定义处,不认 href: '#sec-x' 这类引用(anchor 演示里就有故意指向不存在区块的链接) const claimedDemos = new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.demo)).filter(Boolean)); for (const [, secId] of demoSource.matchAll(/section\(\s*'(sec-[a-z0-9-]+)'/g)) { if (!claimedDemos.has(secId)) issues.push(`apps/demo/main.ts: 演示区块 ${secId} 没有任何组件登记为 demo(孤儿演示:补 docs.components 的 demo,或删掉废弃 section)`); } const skinTotal = pkgs // skin-dom 清单组件总数,供文档数字漂移校验 .filter((p) => p.type === 'skin-dom') .flatMap((p) => p.docs?.components ?? []).length; issues.push(...checkCountDocs(skinTotal)); issues.push(...checkReadme(pkgs)); checkDarkTokens(issues); checkTokenParity(issues); checkLlmsFull(pkgs, issues); issues.push(...checkIterations()); issues.push(...checkVisualChecklist()); if (issues.length) { for (const it of issues) console.error('verify: ' + it); console.error(`${issues.length} violation(s)`); process.exit(1); } console.log('verify OK (manifest ↔ docs ↔ demo 一致,文档 props ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌、暗色令牌齐全、tokens.json ↔ tokens.css 名字与值双源一致、@keyframes 无复制粘贴的重复帧、滚动容器全在细条规则内,README 组件清单与分组同步,backlog ↔ 留痕一致(编号严格递增、无重复 id、留痕时间线单调且带 +08:00),组合配方结构完整,MCP 工具清单三处一致,视觉回归抽查清单十一组件 × 五维度齐全)'); } /*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/ function cmdCommitLint(args) { // 输入来源:文件路径(commit-msg 钩子传参)或参数拼接 let msg = ''; if (args[0] && fs.existsSync(args[0])) msg = fs.readFileSync(args[0], 'utf8'); else msg = args.join(' '); if (!msg.trim()) { console.error('commit-lint: 提交信息为空'); process.exit(1); } const head = msg.split('\n')[0].trimEnd(); // git 自动生成的合并/回滚信息直接放行 if (/^(Merge |Revert |fixup!|squash!)/.test(head)) return console.log('commit-lint OK (git 自动信息,跳过校验)'); const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'iter']; const m = head.match(/^(\w+)(?:\(([^)]*)\))?: (.+)$/); if (!m) { console.error('commit-lint: 首行须为 (): ,规范见 COMMITTING.md'); process.exit(1); } const [, type, scope, subject] = m; const problems = []; // type 枚举 if (!TYPES.includes(type)) problems.push(`type「${type}」不在枚举:${TYPES.join('/')}`); // iter 提交 scope 必须是 backlog 条目 id if (type === 'iter' && !/^[a-z]\d{2,}$/.test(scope ?? '')) problems.push('iter 提交的 scope 必须是 backlog 条目 id(如 b02/e03)'); // 非 iter 提交 scope 必填(杜绝 chore:/docs: 裸 type 放行),且仅允许小写字母与连字符 if (type !== 'iter' && !scope) problems.push(`type「${type}」必须带 scope,如 chore(backlog):`); if (type !== 'iter' && scope && !/^[a-z][a-z-]*$/.test(scope)) problems.push(`scope「${scope}」仅允许小写字母与连字符`); // subject 长度与标点 if ([...subject].length > 50) problems.push(`subject 超过 50 字符(当前 ${[...subject].length})`); if (/[。..!!??]$/.test(subject.trim())) problems.push('subject 结尾不加句号'); // body 行宽 for (const line of msg.split('\n').slice(1)) { if (line.length > 80) problems.push(`body 行超过 80 字符:${line.slice(0, 20)}…`); } if (problems.length) { for (const p of problems) console.error('commit-lint: ' + p); process.exit(1); } console.log(`commit-lint OK (${type}${scope ? `(${scope})` : ''})`); } /*** 提交主题索引:一次 git log 换「短哈希 → 主题」表,供留痕回查,避免逐行起进程 ***/ function commitSubjects() { const r = spawnSync('git', ['log', '--all', '--pretty=%h|%s'], { cwd: ROOT, encoding: 'utf8' }); if (r.status !== 0) return null; // 非 git 环境(打包分发等)跳过该维度,不让整条门禁失效 const pairs = []; for (const line of String(r.stdout ?? '').split('\n')) { const cut = line.indexOf('|'); if (cut > 0) pairs.push([line.slice(0, cut), line.slice(cut + 1)]); } return pairs; } /*** 对比度审计:把令牌的「文字色 × 底色」配对算成 WCAG 比值,低于阈值即退出码 1 * --json 出机器可读明细;--list 只列配对表(看一眼设计契约,不必读代码) */ function cmdAuditContrast(args) { if (args.includes('--list')) { for (const p of PAIRS) console.log(`${p.label}:--ua-color-${p.fg} on --ua-color-${p.bg},阈值 ${p.min}`); return; } let result; try { result = auditContrast(ROOT); } catch (err) { console.error(`audit:contrast 失败:${err.message}`); // 求值不了就红:静默跳过会让人误以为全站达标 process.exit(1); } if (args.includes('--json')) { const pairs = result.rows.map((r) => ({ label: r.label, fg: r.fg, bg: r.bg, min: r.min, light: Number(r.light.toFixed(2)), dark: Number(r.dark.toFixed(2)), contrastLight: Number(r.contrastLight.toFixed(2)), contrastDark: Number(r.contrastDark.toFixed(2)), bad: r.bad, })); console.log(JSON.stringify({ ok: result.failures.length === 0, total: pairs.length, failing: result.failures.length, pairs }, null, 2)); } else { console.log(formatReport(result)); } process.exit(result.failures.length === 0 ? 0 : 1); } /*** 动效兜底审计:带 infinite 的动画必须在 prefers-reduced-motion 下被显式关掉(--json 出明细) * 只盯无限循环动画:有限时长的过渡不会持续刺激,把它也纳入只会让门禁变吵而没人看。 */ function cmdAuditMotion(args) { let result; try { result = auditMotion(ROOT); } catch (err) { console.error(`audit:motion 失败:${err.message}`); process.exit(1); } const failing = result.gaps.length + result.shift.gaps.length; // 两类缺口都算失败:无限动画 + 悬停/按压位移 if (args.includes('--json')) { console.log(JSON.stringify({ ok: failing === 0, total: result.total, failing, entries: result.entries, shift: result.shift }, null, 2)); } else { console.log(formatMotionReport(result)); } process.exit(failing === 0 ? 0 : 1); } /*** 迭代留痕校验:.ua/iterations.log 每行 JSON 字段与格式必须符合 ITERATION.md 规范 ***/ function cmdLintIterations() { const file = path.join(INDEX_DIR, 'iterations.log'); if (!fs.existsSync(file)) return console.log('lint-iterations OK (no log)'); const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((l) => l.trim()); const issues = []; lines.forEach((line, i) => { const tag = `iterations.log:${i + 1}`; let o; try { o = JSON.parse(line); } catch { issues.push(`${tag} 不是合法 JSON`); return; } const keys = Object.keys(o); if (keys.length !== 5 || !['ts', 'id', 'result', 'commit', 'summary'].every((k) => keys.includes(k))) { issues.push(`${tag} 字段必须恰为 {ts,id,result,commit,summary}(当前 ${keys.join(',')})`); } if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/.test(String(o.ts ?? '')) || Number.isNaN(Date.parse(o.ts))) { issues.push(`${tag} ts 须为可解析的本地 +08:00 ISO 时间`); } if (!/^[a-z]\d{2,}$/.test(String(o.id ?? ''))) issues.push(`${tag} id 须为 backlog 条目 id`); if (o.result !== 'done' && o.result !== 'aborted') issues.push(`${tag} result 须为 done|aborted`); const commit = String(o.commit ?? ''); if (o.result === 'done' && !/^[0-9a-f]{7,40}$/.test(commit)) issues.push(`${tag} done 条目 commit 须为 7-40 位哈希`); if (o.result === 'aborted' && commit && !/^[0-9a-f]{7,40}$/.test(commit)) issues.push(`${tag} aborted 条目 commit 须为空或哈希`); if (!String(o.summary ?? '').trim()) issues.push(`${tag} summary 不能为空`); }); // —— 留痕 ↔ 提交双向核对:哈希必须真实存在,且主题要指向同一条目(防伪造 / 防抄错哈希)—— const pairs = commitSubjects(); if (pairs) { lines.forEach((line, i) => { const tag = `iterations.log:${i + 1}`; let o; try { o = JSON.parse(line); } catch { return; } // 行格式问题已在上轮报过 const hash = String(o.commit ?? ''); if (!hash) return; // aborted 允许留空 const hit = pairs.find(([h]) => h.startsWith(hash)); if (!hit) { issues.push(`${tag} commit ${hash} 不在仓库历史中`); return; } const legacy = `iter: ${o.id}`; // 早期提交用「iter: 描述」,无 scope if (!hit[1].startsWith(`iter(${o.id})`) && !hit[1].startsWith(legacy)) { issues.push(`${tag} commit ${hash} 的主题「${hit[1]}」没有指向条目 ${o.id}`); } }); } if (issues.length) { for (const it of issues) console.error('lint-iterations: ' + it); console.error(`${issues.length} violation(s)`); process.exit(1); } console.log(`lint-iterations OK (${lines.length} entries)`); } /*** 环境自检:新 Agent 接手前一条命令确认 node / 依赖 / git 钩子 / .ua 元数据就绪,任一失败退出码 1 ***/ /*** 作用:解析本文件的命令面——dispatch 的 case 集合 vs usage 串里列的命令,两者必须相互覆盖 * 新增命令时只加 case 不改 usage(或反之)会让 Agent 查不到入口:usage 是 Agent 唯一能看到的命令清单。 ***/ function commandSurface() { const self = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8'); const cases = new Set([...self.matchAll(/^ {2}case '([^']+)':/gm)].map((m) => m[1])); const usage = /console\.log\('usage: ua <(.+)>'\)/.exec(self); // 贪婪到行尾括号:usage 里本身含 这类尖括号 // 先剔掉 [--json|--baseline] 这类选项组再按 | 切:否则选项里的竖线会被当成命令分隔符 const listed = usage ? usage[1].replace(/\[[^\]]*\]/g, '').split('|').map((part) => part.trim().split(/\s+/)[0]).filter(Boolean) : []; return { cases, listed }; } function cmdDoctor() { const checks = []; // 逐项结果:名称 + 是否通过 + 可选失败详情 const nodeMajor = Number(process.versions.node.split('.')[0]); checks.push({ name: `node >= 18(当前 ${process.versions.node})`, pass: nodeMajor >= 18 }); checks.push({ name: 'pnpm-lock.yaml 存在', pass: fs.existsSync(path.join(ROOT, 'pnpm-lock.yaml')) }); checks.push({ name: '依赖已安装(node_modules/)', pass: fs.existsSync(path.join(ROOT, 'node_modules')) }); const hooksPath = spawnSync('git', ['config', 'core.hooksPath'], { cwd: ROOT }).stdout?.toString().trim(); // 显式 hooksPath 优先,默认取 .git/hooks const hookDir = hooksPath ? path.resolve(ROOT, hooksPath) : path.join(ROOT, '.git', 'hooks'); checks.push({ name: 'commit-msg 钩子在位', pass: fs.existsSync(path.join(hookDir, 'commit-msg')) }); const gitCfg = (k) => spawnSync('git', ['config', k], { cwd: ROOT }).stdout?.toString().trim() ?? ''; const gitIdOk = !!gitCfg('user.name') && !!gitCfg('user.email'); checks.push({ name: 'git 身份已配置(user.name / user.email)', pass: gitIdOk, detail: gitIdOk ? '' : '先执行 git config --global user.name/user.email,否则提交被拒' }); const tryJson = (rel) => { // 存在且能 JSON.parse 才算过 const p = path.join(ROOT, rel); if (!fs.existsSync(p)) return `文件不存在`; try { JSON.parse(fs.readFileSync(p, 'utf8')); return ''; } catch (e) { return `解析失败:${e.message}`; } }; for (const rel of ['.ua/backlog.json', '.ua/index.json']) { const bad = tryJson(rel); checks.push({ name: `${rel} 可解析`, pass: !bad, detail: bad }); } const logFile = path.join(INDEX_DIR, 'iterations.log'); if (fs.existsSync(logFile)) { const badLines = fs.readFileSync(logFile, 'utf8').split(/\r?\n/).filter((l) => l.trim()).filter((l) => { try { JSON.parse(l); return false; } catch { return true; } }); checks.push({ name: '.ua/iterations.log 每行合法 JSON', pass: !badLines.length, detail: badLines.length ? `${badLines.length} 行非法(首行:${badLines[0].slice(0, 40)}…)` : '' }); } else checks.push({ name: '.ua/iterations.log 存在', pass: false, detail: '尚未生成,跑一轮迭代或 ua index' }); // 命令面自检:usage ↔ dispatch ↔ CLI 清单三方一致,防止新命令只落一处 const { cases, listed } = commandSurface(); const missingInUsage = [...cases].filter((c) => !listed.includes(c)); const missingInCode = listed.filter((c) => !cases.has(c)); checks.push({ name: 'usage 串 ↔ dispatch 一致', pass: !missingInUsage.length && !missingInCode.length, detail: [missingInUsage.length ? `usage 缺 ${missingInUsage.join('/')}` : '', missingInCode.length ? `实现缺 ${missingInCode.join('/')}` : ''].filter(Boolean).join(';'), }); // 门禁链自检:package.json 的 check 必须跑齐所有门禁步骤(新增门禁忘了接进链等于没加) const needSteps = ['build', 'demo', 'style', 'typecheck', 'smoke', 'index', 'verify', 'lint-iterations', 'audit:visual', 'audit:contrast', 'audit:motion']; const chain = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).scripts?.check ?? ''); const lacking = needSteps.filter((step) => !chain.includes(`npm run ${step}`)); checks.push({ name: 'check 链覆盖全部门禁步骤', pass: !lacking.length, detail: lacking.length ? `缺 ${lacking.join(' / ')}` : '' }); // 清单自检:ua.manifest.json 里写进 api.exports 的命令必须真的存在(文档承诺 ≠ 实现) const cliManifestPath = path.join(ROOT, 'packages', 'tools', 'cli', 'ua.manifest.json'); const declared = fs.existsSync(cliManifestPath) ? (JSON.parse(fs.readFileSync(cliManifestPath, 'utf8')).api?.exports ?? []) .filter((line) => /^ua \S/.test(line)) .map((line) => line.replace(/^ua\s+/, '').split(/[\s:]/)[0]) // 说明用全角冒号接在后面,按空白与冒号一起切 : []; const ghost = declared.filter((name) => !cases.has(name)); checks.push({ name: 'CLI 清单声明的命令都有实现', pass: !ghost.length, detail: ghost.length ? `清单有实现无:${ghost.join('/')}` : '' }); for (const c of checks) console.log(`${c.pass ? 'pass' : 'FAIL'} ${c.name}${c.detail ? ':' + c.detail : ''}`); const fails = checks.filter((c) => !c.pass).length; if (fails) { console.error(`doctor: ${fails} 项未通过,先修复再跑 npm run check`); process.exit(1); } console.log(`doctor OK(${checks.length} 项全过)`); } // ---- 入口 ---- const [cmd, ...args] = process.argv.slice(2); switch (cmd) { case 'doctor': cmdDoctor(); break; case 'index': cmdIndex(); break; case 'list': cmdList(args); break; case 'search': cmdSearch(args.filter((a) => !a.startsWith('--')), args); break; case 'info': if (!args[0]) { console.error('usage: ua info <包名|组件id> [--json]'); process.exit(1); } cmdInfo(args[0], args.slice(1)); break; case 'recipes': cmdRecipes(args); break; case 'docs': cmdDocs(); break; case 'style': cmdStyle(); break; case 'sync-counts': cmdSyncCounts(); break; case 'verify': cmdVerify(); break; case 'commit-lint': cmdCommitLint(args); break; case 'changelog': cmdChangelog(args); break; case 'tokens': cmdTokens(args); break; case 'lint-iterations': cmdLintIterations(); break; case 'audit:visual': visualAudit(args, ROOT); break; case 'audit:contrast': cmdAuditContrast(args); break; case 'audit:motion': cmdAuditMotion(args); break; case 'mcp': cmdMcp(); break; default: console.log('usage: ua |info [--json|--md]|recipes [编号] [--json]|docs|tokens [--json|--unused]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|audit:motion [--json]|changelog|mcp>'); process.exit(cmd ? 1 : 0); }