Files
UI/packages/tools/cli/bin/ua.js
T
2026-10-27 02:30:18 +08:00

2264 lines
123 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/*** @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';
import { auditSurface, formatSurfaceReport, LADDER } from '../lib/surface-audit.mjs';
import { SYNONYMS, synonymIds } from '../lib/synonyms.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);
}
/*** 名字 → 包或组件条目:包名 / @ui-arch 前缀 / 组件 id 三种写法都认;CLI info 与 MCP get_component 共用,防止两处解析口径漂移 ***/
function resolveEntry(packages, n) {
const pkg = packages.find((x) => x.name === n || x.name === `@ui-arch/${n}`);
if (pkg) return { kind: 'pkg', pkg };
const hit = collectComponents(packages).find((x) => x.comp.id === n);
return hit ? { kind: 'comp', hit } : null;
}
/*** 组件的可检索字段:权重从高到低(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;
}
/*** 同义回退:整组关键词在 id / 标题 / 参数 / 步骤与配方里都落空时,按同义词表换算出候选组件
* CLI search 与 MCP search_components 共用这一份,避免两处口径漂移;返回空数组表示「回退也无解」 ***/
function fallbackBySynonym(kws) {
const sets = kws.map((kw) => synonymIds(kw));
if (sets.some((s) => !s)) return []; // 有词未被同义词表覆盖:整组回退无从谈起
const all = collectComponents();
let cands = all.filter((x) => sets.every((s) => s.has(x.comp.id))); // 多词取交集,与搜索的 AND 语义保持一致
if (!cands.length) { // 交集为空(两个词各指不同组件)退化为并集:说了两个诉求就把相关项都给到
const union = new Set(sets.flatMap((s) => [...s]));
cands = all.filter((x) => union.has(x.comp.id));
}
return cands;
}
/*** 按关键词搜组件:多词取最弱命中(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));
// 常规两域全空才动用同义词:命中正常时不掺水,免得「搜 select 冒出一堆 drawer」
if (!hits.length && !recipeHits.length) {
for (const x of fallbackBySynonym(kws)) hits.push({ pkg: x.pkg, comp: x.comp, score: 1, field: '同义词', keyword: kws.join(' ') });
}
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 === '同义词' ? ` ← 同义词「${h.keyword}」` : 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} 条配方`);
const viaSyn = hits.length && hits.every((h) => h.field === '同义词'); // 全靠同义回退才算「口语命中」
console.log(`\n共 ${parts.join('、')}命中${viaSyn ? '(常规字段无命中,按同义词表回退)' : ''};查参数:ua info <组件id>,看骨架:ua recipes <编号>`);
}
/*** 输出包或组件信息:默认人读摘要,--json 输出原始 JSON;name 支持逗号分隔一次查多个(单值保持原输出结构不破坏脚本) ***/
function cmdInfo(name, args = []) {
const { packages } = loadIndex();
const asJson = args.includes('--json');
const asMd = args.includes('--md');
const names = name.split(',').map((s) => s.trim()).filter(Boolean); // 批量:逗号分隔多组件一次拿全,省 Agent 往返
const resolve = (n) => resolveEntry(packages, n); // 单个名字 → 包或组件条目;都没命中返回 null 交由调用方报错
if (names.length === 1) {
const r = resolve(names[0]);
if (!r) {
const near = nearIds(names[0]);
console.error(`not found: ${names[0]}(包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`);
return process.exit(1);
}
if (r.kind === 'pkg') {
if (asJson) return console.log(JSON.stringify(r.pkg, null, 2));
return console.log(
`${r.pkg.name} v${r.pkg.version} [${r.pkg.type}] ${r.pkg.description}\n(--json 看完整清单;按组件查:ua info <组件id>)`,
);
}
const { hit } = r;
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((x) => `${x.id}(${x.title})`).join('、')}(ua mcp 的 get_recipe 可取骨架)` : '';
if (asMd) return console.log(formatComponentMd(hit.pkg, hit.comp) + recipeTail); // Markdown 片段:贴进文档 / issue / 回复时不用手抄参数表
return console.log(formatComponent(hit.pkg, hit.comp) + recipeTail);
}
const resolved = names.map((n) => resolve(n)); // 批量路径:先全量解析,任一失败整条拒绝(输出一半再报错会让脚本误判成功)
const bad = names.filter((n, i) => !resolved[i]);
if (bad.length) {
const near = nearIds(bad[0]);
console.error(`not found: ${bad.join(' / ')}(第 ${names.indexOf(bad[0]) + 1} 个,包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`);
return process.exit(1);
}
const compMd = (r) => { // 批量输出统一用组件片段格式:包条目退化为标题行
if (r.kind === 'pkg') return `## ${r.pkg.name}(${r.pkg.type})\n\n${r.pkg.description}`;
const related = recipesFor(r.hit.comp.id);
const tail = related.length ? `\n\n> 出现在配方:${related.map((x) => `${x.id}(${x.title})`).join('、')}` : '';
return formatComponentMd(r.hit.pkg, r.hit.comp) + tail;
};
if (asJson) {
return console.log(JSON.stringify(resolved.map((r) => (r.kind === 'pkg' ? r.pkg : { package: r.hit.pkg.name, ...r.hit.comp, recipes: recipesFor(r.hit.comp.id) })), null, 2));
}
console.log(resolved.map(compMd).join('\n\n---\n\n')); // 片段间以水平线分隔,方便人读与脚本按线切块
}
// ---- 静态文档站生成 ----
/*** HTML 转义:& < > ***/
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/*** 渲染人类文档站:注册表 + 逐组件 props/步骤 + 现场演示 iframe + 令牌,数据内嵌支持搜索 ***/
function renderDocs(index, tokens) {
const data = JSON.stringify(index.packages).replace(/</g, '\\u003c');
const tokData = JSON.stringify(tokens).replace(/</g, '\\u003c');
const recipeData = JSON.stringify(parseRecipes().map((r) => ({ id: r.id, title: r.title, components: recipeComps(r), code: r.code, notes: r.notes }))).replace(/</g, '\\u003c'); // 配方随文档站一起生成:人类也能在浏览器里翻骨架与要点
const generatedAt = esc(index.generatedAt);
// 文档站外壳配色从 tokens.json 派生:手写十六进制会跟令牌脱节(改暗色 accent 后这里还留着旧值,
// 页面与演示 iframe 两个蓝各显示一种),派生后单一来源,改令牌即全站跟随。
const shell = shellPalette(tokens);
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ui-arch 组件库文档</title>
<style>
:root{--bg:${shell.bg};--surface:${shell.surface};--text:${shell.text};--muted:${shell.muted};--border:${shell.border};--accent:${shell.accent};--code-bg:${shell.codeBg};--code-text:${shell.codeText}}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:${shell.darkBg};--surface:${shell.darkSurface};--text:${shell.darkText};--muted:${shell.darkMuted};--border:${shell.darkBorder};--accent:${shell.darkAccent}}}
:root[data-theme=dark]{--bg:${shell.darkBg};--surface:${shell.darkSurface};--text:${shell.darkText};--muted:${shell.darkMuted};--border:${shell.darkBorder};--accent:${shell.darkAccent}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.65 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif}
header{position:sticky;top:0;z-index:2;display:flex;gap:16px;align-items:center;flex-wrap:wrap;padding:14px 24px;background:var(--bg);border-bottom:1px solid var(--border)}
header h1{margin:0;font-size:16px;font-weight:500}
header h1 span{color:var(--muted);font-weight:400;font-size:14px;margin-left:8px}
#q{flex:1;min-width:220px;max-width:420px;margin-left:auto;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;outline:none}
#q:focus{border-color:var(--accent)}
#theme{flex:none;padding:5px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);font-size:12px;cursor:pointer}
#theme:hover{border-color:var(--accent)}
nav.pkgnav{max-width:960px;margin:18px auto 0;padding:0 20px;display:flex;gap:8px;flex-wrap:wrap}
nav.pkgnav a{font-size:12px;color:var(--accent);text-decoration:none;border:1px solid var(--border);border-radius:999px;padding:3px 12px}
main{max-width:960px;margin:0 auto;padding:20px 20px 60px}
.card{border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:22px 24px;margin-bottom:22px}
.card h2{margin:0;font-size:16px;font-weight:500;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.ver{font-size:12px;color:var(--muted)}
.badge{font-size:11px;padding:2px 8px;border-radius:999px;background:var(--accent);color:#fff}
.desc{color:var(--muted);margin:10px 0 0}
.tag{display:inline-block;font-size:11px;padding:2px 9px;margin:10px 6px 0 0;border:1px solid var(--border);border-radius:999px;color:var(--muted)}
.card h3{font-size:13px;font-weight:500;margin:18px 0 8px}
.card ul,.card ol{margin:0;padding-left:18px}
.card li{margin:4px 0}
.comp{border:1px dashed var(--border);border-radius:10px;padding:14px 16px;margin:14px 0 0;background:var(--bg)}
.comp h4{margin:0 0 8px;font-size:14px;font-weight:500;font-family:Consolas,Menlo,monospace}
.comp h5{margin:12px 0 6px;font-size:12px;color:var(--muted);font-weight:500}
table.props{width:100%;border-collapse:collapse;font-size:12.5px;margin:8px 0}
table.props th,table.props td{border:1px solid var(--border);padding:5px 9px;text-align:left;vertical-align:top}
table.props th{background:var(--surface);font-weight:500;color:var(--muted)}
pre{background:var(--code-bg);color:var(--code-text);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12.5px/1.6 Consolas,Menlo,monospace;margin:8px 0}
code{font-family:Consolas,Menlo,monospace;font-size:12.5px}
li code,td code{background:var(--code-bg);color:var(--code-text);padding:1px 6px;border-radius:4px}
details.recipe{border:1px dashed var(--border);border-radius:8px;margin:8px 0;background:var(--bg)}
details.recipe summary{cursor:pointer;padding:8px 12px;font-weight:500;font-size:13px}
details.recipe[open] summary{border-bottom:1px dashed var(--border)}
details.recipe .body{padding:10px 12px}
details.recipe .tag{margin:0 6px 4px 0}
iframe.demo{width:100%;height:430px;border:1px dashed var(--border);border-radius:10px;margin-top:10px;background:var(--surface)}
.sw{display:flex;align-items:center;gap:10px;margin:6px 0}
.sw i{width:26px;height:26px;border-radius:6px;border:1px solid var(--border);flex:none}
.sw code{color:var(--muted)}
.sw span{font-size:12px;color:var(--muted)}
.swatches{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:4px 20px}
.comp h4{display:flex;align-items:center;gap:8px;justify-content:space-between}
.copy{margin-left:auto;flex:none;padding:3px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--muted);font:inherit;font-size:12px;cursor:pointer;transition:color .15s,border-color .15s}
.copy:hover{color:var(--text);border-color:var(--accent)}
.copy:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.empty{color:var(--muted);text-align:center;padding:40px 0}
.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
footer{max-width:960px;margin:0 auto;padding:0 20px 40px;color:var(--muted);font-size:12px}
</style>
</head>
<body>
<header><h1>@ui-arch<span>私人组件库</span></h1><input id="q" type="search" aria-label="搜索组件、参数或 API" placeholder="搜索组件、参数、API…" autocomplete="off"><button id="theme" type="button" aria-label="切换亮暗主题">亮色</button></header>
<nav class="pkgnav" id="nav" aria-label="仓库导航"></nav>
<p class="sr-only" id="hit" role="status" aria-live="polite"></p>
<main id="app"></main>
<footer>由 <code>ua docs</code> 生成于 ${generatedAt} · AI 版全文:<code>llms-full.txt</code> · 重新生成:npm run docs</footer>
<script>
var DATA=${data};
var TOKENS=${tokData};
var RECIPES=${recipeData};
var ORDER={skin-dom:0,'headless-core':1,'design-tokens':2};
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
function propsTable(c){
var rows=(c.props||[]).map(function(p){
return '<tr><td><code>'+esc(p.n)+'</code></td><td><code>'+esc(p.t)+'</code></td><td>'+(p.r?'必填':'否')+'</td><td>'+(p.df?'<code>'+esc(p.df)+'</code>':'—')+'</td><td>'+esc(p.d||'')+(p.k?'(回调)':'')+'</td></tr>';
}).join('');
return '<table class="props"><tr><th>参数</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr>'+rows+'</table>';
}
var ALLCOMPS=[];
DATA.forEach(function(p){ (p.docs&&p.docs.components||[]).forEach(function(c){ ALLCOMPS.push(c); }); });
var TICK=String.fromCharCode(96); // 反引号:生成器本身是模板串,这里不写裸反引号,避免把模板串提前截断
function mdOf(c){
var bar=String.fromCharCode(124); // 竖线:Markdown 表格分隔符的转义源
var cell=function(v){ return String(v||'').split(bar).join('\\\\'+bar); }; // 输出 \\| 给 Markdown 表格转义
var out='### '+c.id+' · '+(c.title||c.id)+'\\n\\n';
if(c.props&&c.props.length){
out+='| 参数 | 类型 | 必填 | 说明 |\\n| --- | --- | --- | --- |\\n';
c.props.forEach(function(p){ out+='| '+TICK+cell(p.n)+TICK+' | '+TICK+cell(p.t)+TICK+' | '+(p.r?'是':'否')+' | '+cell(p.d)+' |\\n'; });
out+='\\n';
}
if(c.steps&&c.steps.length){
out+='**用法**\\n\\n';
c.steps.forEach(function(s,i){ out+=(i+1)+'. '+s+'\\n'; });
out+='\\n';
}
if(c.example) out+=TICK+TICK+TICK+'ts\\n'+c.example+'\\n'+TICK+TICK+TICK+'\\n';
return out;
}
function legacyCopy(text){
var ta=document.createElement('textarea');
ta.value=text; ta.setAttribute('readonly','');
ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
}
document.addEventListener('click',function(e){
var btn=e.target.closest('[data-copy]');
if(!btn) return;
var c=ALLCOMPS.filter(function(x){return x.id===btn.getAttribute('data-copy')})[0];
if(!c) return;
var done=function(){ var old=btn.textContent; btn.textContent='已复制'; setTimeout(function(){ btn.textContent=old; },1200); };
// 剪贴板 API 在 file:// 或未授权时会 reject,退回 textarea + execCommand
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(mdOf(c)).then(done).catch(function(){ legacyCopy(mdOf(c)); done(); });
} else { legacyCopy(mdOf(c)); done(); }
});
function compBlock(c){
var steps=(c.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
return '<div class="comp" id="c-'+esc(c.id)+'"><h4>'+esc(c.id)+' · '+esc(c.title||'')
+'<button class="copy" type="button" data-copy="'+esc(c.id)+'" title="复制为 Markdown 参数表,可直接贴进文档或 issue">复制参数表</button></h4>'
+(c.props?propsTable(c):'')
+(c.example?'<pre><code>'+esc(c.example)+'</code></pre>':'')
+(steps?'<h5>手把手</h5><ol>'+steps+'</ol>':'')
+(c.demo?'<iframe class="demo" src="demo.html#'+esc(c.demo)+'" loading="lazy" title="'+esc(c.id)+' 现场演示"></iframe>':'')
+'</div>';
}
function pkgCard(p){
var steps=(p.docs&&p.docs.steps||[]).map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
var comps=(p.docs&&p.docs.components||[]).map(compBlock).join('');
var ex=(p.api&&p.api.exports||[]).map(function(e){return '<li><code>'+esc(e)+'</code></li>'}).join('');
var conv=(p.conventions||[]).map(function(c){return '<li>'+esc(c)+'</li>'}).join('');
var tags=(p.tags||[]).map(function(t){return '<span class="tag">'+esc(t)+'</span>'}).join('');
return '<section class="card" id="p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'
+'<h2>'+esc(p.name)+' <span class="ver">v'+esc(p.version)+'</span> <span class="badge">'+esc(p.type)+'</span></h2>'
+'<p class="desc">'+esc((p.docs&&p.docs.intro)||p.description)+'</p><div>'+tags+'</div>'
+(steps?'<h3>手把手接入</h3><ol>'+steps+'</ol>':'')
+(comps?'<h3>组件明细</h3>'+comps:'')
+(ex?'<h3>API 速览</h3><ul>'+ex+'</ul>':'')
+(p.usage?'<h3>一行用法</h3><pre><code>'+esc(p.usage)+'</code></pre>':'')
+(conv?'<h3>约定</h3><ul>'+conv+'</ul>':'')
+'</section>';
}
function quickstart(){
var ui=DATA.filter(function(p){return p.docs&&p.docs.steps}).sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
if(!ui.length)return '';
var blocks=ui.map(function(p){
var steps=p.docs.steps.map(function(s){return '<li>'+esc(s)+'</li>'}).join('');
return '<p class="desc" style="margin:10px 0 4px"><b>'+esc(p.name)+'</b></p><ol>'+steps+'</ol>';
}).join('');
return '<section class="card"><h2>快速上手 <span class="badge">guide</span></h2>'+blocks+'</section>';
}
function tokensCard(){
if(!TOKENS||!TOKENS.color)return '';
var sw=Object.keys(TOKENS.color).map(function(k){
var v=TOKENS.color[k];
var light=typeof v==='string'?v:v.light;
var dark=typeof v==='string'?v:v.dark;
var extra=dark&&dark!==light?' / '+dark:'';
return '<div class="sw"><i style="background:'+light+'"></i><code>--ua-'+esc(k)+'</code><span>'+light+extra+'</span></div>';
}).join('');
return '<section class="card"><h2>@ui-arch/tokens <span class="badge">design-tokens</span></h2><p class="desc">皮肤包只允许消费 --ua-* CSS 变量;暗色主题覆盖值不换名(light / dark)。</p><h3>颜色令牌</h3><div class="swatches">'+sw+'</div></section>';
}
function recipesCard(){
if(!RECIPES.length)return '';
var items=RECIPES.map(function(r){
var chips=(r.components||[]).map(function(c){return '<span class="tag">'+esc(c)+'</span>'}).join('');
var notes=(r.notes||[]).map(function(n){return '<li>'+esc(n)+'</li>'}).join('');
return '<details class="recipe"><summary>配方 '+esc(r.id)+':'+esc(r.title)+'</summary><div class="body"><div>'+chips+'</div><pre><code>'+esc(r.code)+'</code></pre>'+(notes?'<ul>'+notes+'</ul>':'')+'</div></details>';
}).join('');
return '<section class="card" id="recipes"><h2>组合配方 <span class="badge">recipes</span></h2><p class="desc">跨组件拼装的最小可运行骨架与要点,展开即看代码;Agent 侧对应 MCP get_recipe / ua recipes。</p>'+items+'</section>';
}
function render(list,q){
var app=document.getElementById('app');
if(!list.length){app.innerHTML='<p class="empty">无匹配结果</p>';var none=document.getElementById('hit');if(none)none.textContent='无匹配结果';return}
app.innerHTML=quickstart()+list.map(pkgCard).join('')+(list.some(function(p){return p.name.indexOf('tokens')>=0})?'':tokensCard())+(q?'':recipesCard()); // 搜索态不渲染配方卡:它不属于任何包,避免过滤结果被无关区块稀释
var hit=document.getElementById('hit'); // 搜索结果对读屏播报
if(hit)hit.textContent='匹配 '+list.length+' 个包';
}
function norm(p){
var parts=[p.name,p.description,p.type,(p.tags||[]).join(' '),(p.api&&p.api.exports||[]).join(' ')];
(p.docs&&p.docs.components||[]).forEach(function(c){parts.push(c.id,c.title||'',(c.props||[]).map(function(x){return x.n}).join(' '))});
return parts.join(' ').toLowerCase();
}
var ordered=DATA.slice().sort(function(a,b){return (ORDER[a.type]??9)-(ORDER[b.type]??9)});
document.getElementById('nav').innerHTML=ordered.map(function(p){return '<a href="#p-'+esc(p.name.replace(/[^\\w-]/g,''))+'">'+esc(p.name)+'</a>'}).join('')+'<a href="#recipes">配方</a><a href="#top-tokens">令牌</a>';
render(ordered,'');
document.getElementById('q').addEventListener('input',function(e){
var q=e.target.value.trim().toLowerCase();
render(q?ordered.filter(function(p){return norm(p).indexOf(q)>=0}):ordered,q);
});
// —— 主题:默认跟随系统(不写 data-theme),用户选择后持久化;iframe 演示带参重载联动 ——
var THEME_KEY='ua-docs-theme';
var themeBtn=document.getElementById('theme');
function paintTheme(t){ // t: 'light'|'dark'|null,null 表示跟随系统
if(t)document.documentElement.setAttribute('data-theme',t);else document.documentElement.removeAttribute('data-theme');
themeBtn.textContent=t==='dark'?'暗色':'亮色';
}
var savedTheme=null;try{savedTheme=localStorage.getItem(THEME_KEY)}catch(e){}
paintTheme(savedTheme==='dark'||savedTheme==='light'?savedTheme:null);
themeBtn.addEventListener('click',function(){
var cur=document.documentElement.getAttribute('data-theme')||(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');
var next=cur==='dark'?'light':'dark';
try{localStorage.setItem(THEME_KEY,next)}catch(e){}
paintTheme(next);
document.querySelectorAll('iframe.demo').forEach(function(f){ // 带参重载:demo 读 ?theme= 切换
var parts=f.src.split('#');var anchor=parts.length>1?'#'+parts.slice(1).join('#'):'';
f.src=parts[0].split('?')[0]+'?theme='+next+anchor;
});
});
</script>
</body>
</html>
`;
}
/*** 读取组合配方(与 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 or more components by comma-separated ids (e.g. "button,select"); format=md returns paste-ready Markdown blocks (multi-query blocks are separated by horizontal rules)',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'component id (e.g. inputTag), package name (e.g. @ui-arch/table-core), or comma-separated ids for batch query (e.g. button,select)' },
format: { type: 'string', enum: ['json', 'md'], description: 'json (default) returns the manifest entry (array for batch); 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 names = String(args.name).split(',').map((s) => s.trim()).filter(Boolean); // 批量:逗号分隔一次拿全多个组件,与 ua info 同口径
const resolved = names.map((n) => resolveEntry(packages, n));
const bad = names.filter((n, i) => !resolved[i]);
if (bad.length) throw new Error(`not found: ${bad.join(' / ')}(第 ${names.indexOf(bad[0]) + 1} 个,包名与组件 id 都没命中)`);
const compMd = (r) => { // md 模式组件片段 + 配方尾注;包条目退化为标题行
if (r.kind === 'pkg') return `## ${r.pkg.name}(${r.pkg.type})\n\n${r.pkg.description}`;
const related = recipesFor(r.hit.comp.id);
const tail = related.length ? `\n\n出现在配方:${related.map((x) => `${x.id}(${x.title})`).join('、')}` : '';
return formatComponentMd(r.hit.pkg, r.hit.comp) + tail;
};
if (resolved.length > 1) {
if (args.format === 'md') return resolved.map(compMd).join('\n\n---\n\n'); // 多段片段以水平线分隔
return resolved.map((r) => (r.kind === 'pkg' ? r.pkg : { package: r.hit.pkg.name, ...r.hit.comp, recipes: recipesFor(r.hit.comp.id) }));
}
const r = resolved[0];
if (r.kind === 'pkg') return r.pkg;
if (args.format === 'md') return compMd(r); // 与 ua info --md 共用同一渲染:Agent 拿到就能直接贴进回复 / 文档,不必自己排参数表
return { package: r.hit.pkg.name, ...r.hit.comp, recipes: recipesFor(r.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) {
// 三域全空时与 CLI 同源回退:Agent 常拿到中文口语词(「弹窗」),不兜底就只能反复试错
const syn = fallbackBySynonym([q]).map(({ pkg, comp }) => ({ package: pkg.name, id: comp.id, title: comp.title, matched: { field: '同义词', keyword: q } }));
if (!syn.length) throw new Error(`no match: ${args.query}`);
return { packages: pkgs, components: syn, recipes: recs };
}
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(/<!-- ua:components -->([\s\S]*?)<!-- \/ua:components -->/);
if (!block) {
issues.push('README.md 缺少组件区块标记(<!-- ua:components --> … <!-- /ua:components -->)');
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(`(?<![A-Za-z0-9])${id}(?![A-Za-z0-9])`, 'g')) ?? []; // 整词计数,避开 formItem 命中 form
if (!hits.length) issues.push(`README.md 分组行缺少 ${id}`);
else if (hits.length > 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', 'form', 'modal', 'dataTable', 'toast', 'layout', 'jsonViewer', 'diffView', 'codeBlock', 'signaturePad', 'heatmap', 'sparkline', 'commandPalette', 'noticeBar', 'progressRing', 'sortableList', 'masonry']; // 十九个代表组件:高频六个 + 表单容器(form) + 容器/数据展示/表单录入三类 + 后期新增的浮层类(命令面板)与反馈类(通知条、环形进度)+ 交互类(拖拽排序)与布局类(瀑布流)
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 锚点必须与代码和演示产物对齐 ***/
/*** 校验输入控件的可访问名:role=combobox / slider 与原生 range 不自带名字,缺 aria-label 时读屏只念「编辑框 / 滑块」
* 只查这三类「必须有名字」的控件,包裹式 <label> 与 aria-labelledby 都算通过,避免误伤复用标签的写法 ***/
function checkAriaNames(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue; // 只有皮肤包会产生 DOM
const srcDir = path.join(ROOT, pkg.dir, 'src');
if (!fs.existsSync(srcDir)) continue;
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith('.ts'))) {
const lines = fs.readFileSync(path.join(srcDir, file), 'utf8').split(/\r?\n/); // 归一换行:CRLF 文件不会把一行拆成两半
lines.forEach((line, i) => {
if (!/role: '(combobox|slider)'|type: 'range'/.test(line)) return; // 卫语句:只关心这三类无名即失声的控件
let head = i; // 回退到 h('input' / h('textarea' / h('select' 那一行:一处控件的属性只在同一次调用里
while (head > 0 && !/h\('(input|textarea|select)'/.test(lines[head])) head--;
let tail = i; // 前进到这次调用的收尾行(`})` 或 `}) as ...`),窗口只取这一个创建块
while (tail < lines.length - 1 && !/\}\)/.test(lines[tail])) tail++;
const block = lines.slice(head, tail + 1).join(' ');
if (/aria-label|aria-labelledby/.test(block)) return; // 同名两法都算过(后者由外部 label 提供)
if (/setAttribute\('aria-label'/.test(lines.slice(tail + 1, tail + 4).join(' '))) return; // 也认「建完立刻 setAttribute」的写法
issues.push(`${pkg.name}/${file}:${i + 1} 的 ${line.trim().slice(0, 40)} 缺可访问名(role=combobox / slider 与 range 都不会从内容里取名,读屏只念控件类型)`);
});
}
}
return issues;
}
/*** 校验 toast 的 live 区域:动态插入的通知读屏听不到容器外的变化,缺 aria-live / alert 抢占等于哑巴播报 ***/
function checkToastLive(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue;
const toastPath = path.join(ROOT, pkg.dir, 'src', 'toast.ts');
if (!fs.existsSync(toastPath)) continue;
const src = fs.readFileSync(toastPath, 'utf8');
if (!/aria-live/.test(src)) issues.push(`${pkg.name}/src/toast.ts 缺 aria-live 容器:toast 是动态插入 DOM 的,没有 live 区域读屏用户感知不到通知出现`);
if (!/role: props\.variant === 'danger' \? 'alert' : 'status'/.test(src)) issues.push(`${pkg.name}/src/toast.ts 缺 alert 抢占播报:error 级别必须用 role=alert 打断读屏,其余才降级 polite`);
}
return issues;
}
/*** 校验表单错误关联:只标 aria-invalid 时读屏只说「无效」,原因(哪条规则没过)听不到 ***/
function checkFormErrorLink(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue;
const formPath = path.join(ROOT, pkg.dir, 'src', 'form.ts');
if (!fs.existsSync(formPath)) continue;
const src = fs.readFileSync(formPath, 'utf8');
if (!/id: `\$\{fid\}-error`/.test(src)) issues.push(`${pkg.name}/src/form.ts 的错误区缺派生 id:没有 id 就无法被 aria-describedby 指过来`);
if (!/setAttribute\('aria-describedby', described\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 aria-describedby 落笔:校验失败时读屏只会念「无效」,念不出「至少 6 个字符」这类原因`);
if (!/removeAttribute\('aria-describedby'\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 aria-describedby 清除:校验通过后关联还指着错误区,读屏会把空节点当说明`);
}
return issues;
}
/*** 校验表单重置:只跑 onReset 回调等于「点了没反应」,按钮名叫重置就得真复位 ***/
function checkFormReset(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue;
const formPath = path.join(ROOT, pkg.dir, 'src', 'form.ts');
if (!fs.existsSync(formPath)) continue;
const src = fs.readFileSync(formPath, 'utf8');
if (!/return \{ el, validate, validateAsync, reset \}/.test(src)) issues.push(`${pkg.name}/src/form.ts 的字段句柄没暴露 reset:form 拿不到复位入口`);
if (!/for \(const item of items\) item\.reset\(\);/.test(src)) issues.push(`${pkg.name}/src/form.ts 的重置没逐项复位:只调 onReset 时点「重置」等于什么都不干,红框还留在屏幕上`);
if (!/type: 'button', onClick: doReset/.test(src)) issues.push(`${pkg.name}/src/form.ts 的重置按钮没接到 doReset:会退回只跑回调的空壳`);
}
return issues;
}
/*** 校验错误环:红环常驻会把长表单染成一片警报,也会与相邻字段的环叠加——环只该在聚焦时亮 ***/
function checkInvalidRing(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue;
const cssPath = path.join(ROOT, pkg.dir, 'ui.css');
if (!fs.existsSync(cssPath)) continue;
const lines = fs.readFileSync(cssPath, 'utf8').split(/\r?\n/);
let hasFocusRing = false; // 聚焦态危险环是否已单独写过
lines.forEach((line, i) => {
if (!line.trim().endsWith('{')) return; // 卫语句:只从选择器收尾行起算(一选择器一行是仓库既有排版)
let top = i; // 选择器首行:往上吃掉所有逗号收尾的续行
while (top > 0 && lines[top - 1].trim().endsWith(',')) top--;
const selector = lines.slice(top, i + 1).join(' ');
if (!selector.includes('.ua-invalid')) return; // 卫语句:与本规则无关的块
let body = ''; // 规则体:拼到第一行收尾的 }
for (let k = i + 1; k < lines.length && !lines[k].trim().startsWith('}'); k++) body += lines[k];
if (!/focus-ring-danger/.test(body)) return; // 卫语句:本块不涉及危险环
if (/focus/.test(selector)) hasFocusRing = true;
else issues.push(`${pkg.name}/ui.css:${i + 1} 的「${selector.trim().slice(0, 60)}」常驻危险光环:校验失败但未聚焦的字段也挂着 3px 红环,长表单看着像整页报错`);
});
if (!hasFocusRing) issues.push(`${pkg.name}/ui.css 缺 .ua-invalid:focus-visible 的危险环:改完常驻环后聚焦态反而没有错误强调`);
}
return issues;
}
/*** 校验汇总条实时性:条子只在提交时算一次,用户改好字段后「有 3 项需要修改」会一直骗人 ***/
function checkFormSummary(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue;
const formPath = path.join(ROOT, pkg.dir, 'src', 'form.ts');
if (!fs.existsSync(formPath)) continue;
const src = fs.readFileSync(formPath, 'utf8');
if (!/dispatchEvent\(new CustomEvent\('ua:field-validated'\)\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 的字段落笔没有通知:表单算不出实时错误数`);
if (!/addEventListener\('ua:field-validated', refreshSummary\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 没订阅字段校验事件:汇总条会停在提交那一刻的数字`);
if (!/if \(quiet \|\| summary\.hidden\) return;/.test(src)) issues.push(`${pkg.name}/src/form.ts 的重算缺自激防护:validate → paint → 事件 → 再 validate 会无限递归`);
}
return issues;
}
/*** 校验提交忙碌态:异步规则跑起来时按钮只变灰,用户会以为点空了;也防「设了 loading 忘了复位」 ***/
function checkFormBusy(pkgs) {
const issues = [];
for (const pkg of pkgs) {
if (pkg.type !== 'skin-dom') continue;
const formPath = path.join(ROOT, pkg.dir, 'src', 'form.ts');
if (!fs.existsSync(formPath)) continue;
const src = fs.readFileSync(formPath, 'utf8');
if (!/loading: busy/.test(src)) issues.push(`${pkg.name}/src/form.ts 的提交按钮没接 loading:异步校验期间只变灰,用户看不出在等`);
if (!/busy\(false\)/.test(src)) issues.push(`${pkg.name}/src/form.ts 缺 busy(false) 复位:按钮会一直转圈,表单再也提交不了`);
if (/submitBtn\.disabled = true/.test(src)) issues.push(`${pkg.name}/src/form.ts 又手写 submitBtn.disabled:与 loading 双写会让禁用态漏复位,只留 loading 一条路`);
}
return issues;
}
/*** 校验同义词表:目标 id 必须真实存在(改名后残留会让回退指到空气),同一个词不得归两组(歧义会让结果随表序漂移) ***/
function checkSynonyms(ids) {
const issues = [];
const seen = new Map(); // 词 → 已登记的组件 id(用于报重复)
for (const group of SYNONYMS) {
const tag = `同义词「${group.words[0]}」`;
if (!(group.words ?? []).length) issues.push('同义词表有一组缺少 words');
if (!(group.ids ?? []).length) issues.push(`${tag}: 缺少 ids`);
for (const id of group.ids ?? []) if (!ids.has(id)) issues.push(`${tag}: 目标组件 ${id} 不在清单里(改名后忘改同义词表)`);
for (const w of group.words ?? []) {
if (seen.has(w)) issues.push(`${tag}: 词「${w}」已归入同义词「${seen.get(w)}」(一词只许一组,否则回退结果随表序变化)`);
seen.set(w, group.words[0]);
}
}
return issues;
}
/*** 校验清单 ↔ 文档 ↔ 演示一致,任一项不符即退出码 1(口径见 AGENTS.md 「门禁一条龙」) ***/
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 < 4) issues.push(`${tag}: steps 少于 4 条(每件至少给 4 步:怎么传 / 怎么联动 / 边界行为 / 什么时候别用它)`);
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));
issues.push(...checkAriaNames(pkgs)); // 输入控件必须有可访问名
issues.push(...checkToastLive(pkgs)); // toast 必须有 live 区域与 alert 抢占
issues.push(...checkFormErrorLink(pkgs)); // 表单错误文案必须被控件关联
issues.push(...checkFormReset(pkgs)); // 重置按钮必须真复位
issues.push(...checkInvalidRing(pkgs)); // 错误环只该在聚焦时亮
issues.push(...checkFormSummary(pkgs)); // 汇总条必须实时重算
issues.push(...checkFormBusy(pkgs)); // 提交忙碌态必须有可见反馈
issues.push(...checkSynonyms(new Set(pkgs.flatMap((p) => (p.docs?.components ?? []).map((c) => c.id))))); // 同义词表指向的组件必须真实存在
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,每个 iter 提交都有留痕),组合配方结构完整,MCP 工具清单三处一致,搜索同义词表目标存在且无歧义,combobox / slider / range 控件都有可访问名,toast 有 live 区域与 alert 抢占播报,表单错误文案被 aria-describedby 关联、重置真复位、错误环只在聚焦时亮、汇总条随校验重算、提交按钮有忙碌态,视觉回归抽查清单十九组件 × 五维度齐全)');
}
/*** 校验 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: 首行须为 <type>(<scope>): <subject>,规范见 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 + result.spatial.gaps.length; // 三类缺口都算失败:无限动画 + 悬停/按压位移 + 旋转/缩放过渡
if (args.includes('--json')) {
console.log(JSON.stringify({ ok: failing === 0, total: result.total, failing, entries: result.entries, shift: result.shift, spatial: result.spatial }, null, 2));
} else {
console.log(formatMotionReport(result));
}
process.exit(failing === 0 ? 0 : 1);
}
/*** 表面阶梯审计:底色对底色(悬停 / 按下 / 凹陷)差得看不出就是没做(--json 出明细、--list 只看阶梯表) ***/
function cmdAuditSurface(args) {
if (args.includes('--list')) {
for (const s of LADDER) console.log(`${s.label}:--ua-color-${s.from} → --ua-color-${s.to},亮度比阈值 ${s.min}`);
return;
}
let result;
try {
result = auditSurface(ROOT);
} catch (err) {
console.error(`audit:surface 失败:${err.message}`); // 求值不了就红:静默跳过等于默许阶梯糊在一起
process.exit(1);
}
if (args.includes('--json')) {
const steps = result.rows.map((r) => ({ ...r, ratio: Number(r.ratio.toFixed(3)) }));
console.log(JSON.stringify({ ok: result.failures.length === 0, total: steps.length, failing: result.failures.length, steps }, null, 2));
} else {
console.log(formatSurfaceReport(result));
}
process.exit(result.failures.length === 0 ? 0 : 1);
}
/*** 迭代循环诊断:锁心跳 / 上次留痕 / 待办数一眼看清,多实例接力时不必翻调度器库或靠猜(--json 给脚本用) ***/
function cmdLoop(args) {
const now = Math.floor(Date.now() / 1000);
const lockFile = path.join(INDEX_DIR, 'loop.lock');
let lock = null;
if (fs.existsSync(lockFile)) {
const raw = Number(fs.readFileSync(lockFile, 'utf8').trim()); // 锁内是点火时的 epoch 秒
const stamp = raw || Math.floor(fs.statSync(lockFile).mtimeMs / 1000); // 取不到数字就用 mtime 兜底
const age = now - stamp;
lock = { stamp, age, fresh: age < 900 }; // 15 分钟内的心跳算活着,与 ITERATION.md 的抢锁判据同口径
}
const logFile = path.join(INDEX_DIR, 'iterations.log');
const text = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
const lines = text.split('\n').filter((l) => l.trim()); // 按行切即可:JSON.parse 容忍行尾 \r,不必额外归一化
let last = null;
try { last = lines.length ? JSON.parse(lines[lines.length - 1]) : null; } catch { last = null; } // 末行坏了不该让诊断命令崩,lint-iterations 会单独报
const data = JSON.parse(fs.readFileSync(path.join(INDEX_DIR, 'backlog.json'), 'utf8'));
const items = Array.isArray(data) ? data : data.items;
const open = items.filter((i) => i.status === 'open');
const pending = open.filter((i) => i.domain === 'visual');
const nextId = `g${Math.max(0, ...items.map((i) => Number(String(i.id).replace(/^g/, '')) || 0)) + 1}`; // 下一条可用编号:自补条目直接照抄,省得撞号后回头 rebase
if (args.includes('--json')) {
const out = { now, lock, entries: lines.length, last, total: items.length, open: open.length, openVisual: pending.length, nextId };
console.log(JSON.stringify(out, null, 2));
return;
}
console.log(`当前时间:${new Date(now * 1000).toLocaleString('zh-CN', { hour12: false })}`);
if (!lock) console.log('循环锁:无 → 可直接点火(写 .ua/loop.lock 后进连续迭代)');
else if (lock.fresh) console.log(`循环锁:心跳 ${lock.age} 秒前(< 15 分钟)→ 已有实例在跑,本轮不要抢锁`);
else console.log(`循环锁:心跳 ${lock.age} 秒前(≥ 15 分钟)→ 疑似中断,可删锁重新点火`);
console.log(last ? `上次留痕:${last.id} ${last.result} @ ${last.ts}(${last.commit})` : '上次留痕:无');
console.log(`累计留痕:${lines.length} 条|backlog:${items.length} 条,open ${open.length} 条(视觉欠账 ${pending.length} 条)`);
if (!open.length) console.log(`选题:无 open 条目 → 本轮需按《选题规则》自补一条(下一条可用编号 ${nextId})`);
else console.log(`选题:下一条取 ${pending[0]?.id ?? open[0].id}(${pending[0] ? '视觉欠账优先' : '按数组顺序'})`);
}
/*** 迭代留痕校验:.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: <id> 描述」,无 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 里本身含 <kw> 这类尖括号
// 先剔掉 [--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', 'audit:surface'];
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} 项全过)`);
}
/*** 作用:把「改 backlog + 追加 iterations.log」收成一条命令——字段顺序与 +08:00 时间格式由代码保证,
* 省掉每轮手搓临时脚本(历史踩过:ts 手写、字段顺序自创、状态忘了改、哈希抄错)。 ***/
const localStamp = () => {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
const off = -d.getTimezoneOffset(); // 时区偏移(分钟),东八区为 +480
const sign = off >= 0 ? '+' : '-';
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`;
};
function cmdIter(args) {
const [sub, ...rest] = args;
const backlogFile = path.join(INDEX_DIR, 'backlog.json');
const logFile = path.join(INDEX_DIR, 'iterations.log');
const load = () => JSON.parse(fs.readFileSync(backlogFile, 'utf8'));
const itemsOf = (data) => (Array.isArray(data) ? data : data.items);
const save = (data, items) => {
if (Array.isArray(data)) fs.writeFileSync(backlogFile, JSON.stringify(items, null, 2) + '\n', 'utf8');
else { data.items = items; fs.writeFileSync(backlogFile, JSON.stringify(data, null, 2) + '\n', 'utf8'); }
};
if (sub === 'done') {
const [id, commit, summary, notes] = rest;
if (!id || !commit || !summary) {
console.error('usage: ua iter done <id> <commit> <summary> [notes]');
process.exit(2);
}
if (!/^[0-9a-f]{7,40}$/.test(commit)) { console.error(`commit 须为 7-40 位短哈希:${commit}`); process.exit(2); }
const data = load();
const items = itemsOf(data);
const item = items.find((i) => i.id === id);
if (!item) { console.error(`backlog 里没有 ${id}(自补条目先用 ua iter add)`); process.exit(2); }
item.status = 'done';
if (notes) item.notes = notes; // 备注缺省保留原值:只有显式传了才覆盖
save(data, items);
const line = { ts: localStamp(), id, result: 'done', commit, summary }; // 字段顺序与 ITERATION.md 一致
fs.appendFileSync(logFile, JSON.stringify(line) + '\n', 'utf8');
console.log(`iter done ${id} → backlog 置 done,留痕 ${line.ts}(${commit})`);
console.log('接着提交元数据:git add -A && git commit -m "chore(backlog): 记录 ' + id + ' 完成"');
return;
}
if (sub === 'add') {
const [file] = rest;
if (!file) { console.error('usage: ua iter add <条目json文件>'); process.exit(2); }
const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
const missing = ['id', 'domain', 'title', 'motivation', 'acceptance'].filter((k) => !entry[k]); // 选题规则要求三件套齐全
if (missing.length) { console.error(`条目缺字段:${missing.join(' / ')}`); process.exit(2); }
const data = load();
const items = itemsOf(data);
if (items.some((i) => i.id === entry.id)) { console.error(`id 已存在:${entry.id}`); process.exit(2); }
items.push({ ...entry, status: entry.status ?? 'open', notes: entry.notes ?? '' });
save(data, items);
console.log(`iter add ${entry.id}(${entry.domain}):${entry.title}`);
return;
}
console.error('usage: ua iter <done <id> <commit> <summary> [notes] | add <条目json文件>>');
process.exit(2);
}
// ---- 入口 ----
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 'audit:surface':
cmdAuditSurface(args);
break;
case 'loop':
cmdLoop(args);
break;
case 'iter':
cmdIter(args);
break;
case 'mcp':
cmdMcp();
break;
default:
console.log('usage: ua <index|list|search <kw>|info <name[,name…]> [--json|--md]|recipes [编号] [--json]|docs|tokens [--json|--unused]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|iter|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|audit:motion [--json]|audit:surface [--json|--list]|loop [--json]|changelog|mcp>');
process.exit(cmd ? 1 : 0);
}