Files
UI/packages/tools/cli/bin/ua.js
T
HIsheR 06091757c7 iter(g153): ua search 支持多关键词
多词按「最弱命中」做 AND 过滤,排序仍按 id > 标题 > props > 步骤。
未命中输出相近建议并以 1 退出,脚本能直接判断成败。
2026-10-26 15:53:21 +08:00

1334 lines
63 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';
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('');
}
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('--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');
}
/*** 反查某组件出现在哪些配方里:查参数时顺带给出「它通常和谁一起用」的组合线索 ***/
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);
}
/*** 按关键词搜组件:多词取最弱命中(AND 语义),id 命中优先于 title,其次 props 名与步骤文案;未命中退出码 1 ***/
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 scoreOf = (kw, comp) => {
const id = String(comp.id ?? '').toLowerCase();
const title = String(comp.title ?? '').toLowerCase();
const props = (comp.props ?? []).map((p) => String(p.n ?? '').toLowerCase()).join(' ');
const steps = (comp.steps ?? []).join(' ').toLowerCase();
if (id === kw) return 4; // 分档只用于排序:id 全等 > id 含 > 标题 > props 名 > 步骤
if (id.includes(kw)) return 3;
if (title.includes(kw)) return 2;
if (props.includes(kw)) return 1;
return steps.includes(kw) ? 0.5 : 0;
};
const hits = collectComponents()
.map(({ pkg, comp }) => {
const scores = kws.map((kw) => scoreOf(kw, comp));
return scores.every((s) => s > 0) ? { pkg, comp, score: Math.min(...scores) } : null; // 任一词不中即淘汰
})
.filter(Boolean)
.sort((a, b) => b.score - a.score || String(a.comp.id).localeCompare(String(b.comp.id)));
if (!hits.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(hits.map((h) => ({ package: h.pkg.name, ...h.comp })), null, 2));
}
for (const h of hits) console.log(`${String(h.comp.id).padEnd(16)} ${h.comp.title ?? ''} (${h.pkg.name})`);
console.log(`\n共 ${hits.length} 个组件命中;查参数:ua info <组件id>`);
}
/*** 输出单包或单组件信息:默认人读摘要,--json 输出原始 JSON ***/
function cmdInfo(name, args = []) {
const { packages } = loadIndex();
const asJson = args.includes('--json');
const pkg = packages.find((x) => x.name === name || x.name === `@ui-arch/${name}`);
if (pkg) {
if (asJson) return console.log(JSON.stringify(pkg, null, 2));
return console.log(
`${pkg.name} v${pkg.version} [${pkg.type}] ${pkg.description}\n(--json 看完整清单;按组件查:ua info <组件id>)`,
);
}
const hit = collectComponents(packages).find((x) => x.comp.id === name);
if (!hit) {
const near = nearIds(name);
console.error(`not found: ${name}(包名与组件 id 都没命中)${near.length ? `;相近:${near.join(' / ')}` : ''}`);
return process.exit(1);
}
if (asJson) return console.log(JSON.stringify({ package: hit.pkg.name, ...hit.comp, recipes: recipesFor(hit.comp.id) }, null, 2));
const related = recipesFor(hit.comp.id);
const tail = related.length ? `\n\n出现在配方:${related.map((r) => `${r.id}(${r.title})`).join('、')}(ua mcp 的 get_recipe 可取骨架)` : '';
console.log(formatComponent(hit.pkg, hit.comp) + tail);
}
// ---- 静态文档站生成 ----
/*** 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 generatedAt = esc(index.generatedAt);
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:#ffffff;--surface:#f6f7f9;--text:#1c1e21;--muted:#6b7280;--border:rgba(0,0,0,.14);--accent:#185fa5;--code-bg:#101216;--code-text:#e7e9ec}
@media (prefers-color-scheme:dark){:root{--bg:#101216;--surface:#1a1c22;--text:#e7e9ec;--muted:#888780;--border:rgba(255,255,255,.15);--accent:#378add}}
*{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)}
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}
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}
.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"></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 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>';
}
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||'')+'</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 render(list){
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());
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="#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);
});
</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;
}
/*** 渲染 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);
}
if (args.includes('--json')) {
console.log(JSON.stringify(list, null, 2));
return;
}
for (const t of list) console.log(`${t.cssVar.padEnd(26)} ${t.value}${t.dark && t.dark !== t.value ? ' / 暗色 ' + t.dark : ''}`);
console.log(`共 ${list.length} 个令牌`);
}
// ---- 内置 stdio MCP server(JSON-RPC 2.0,按行分帧) ----
/*** 启动 MCP server:向 Agent 暴露 list / get / search / tokens / recipes 六类查询工具 ***/
function cmdMcp() {
if (!fs.existsSync(INDEX_FILE)) cmdIndex();
const { packages } = loadIndex();
const tools = [
{
name: 'list_components',
description: 'List all packages/components in the @ui-arch private library',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'get_component',
description: 'Get one package by name, or one component by its id (returns props/steps/example)',
inputSchema: {
type: 'object',
properties: { name: { type: 'string', description: 'package name, e.g. @ui-arch/table-core or table-core' } },
required: ['name'],
},
},
{
name: 'search_components',
description: 'Search packages by keyword across name/description/tags/api',
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(',')}`); // 常量是文档校验的基准,先自证
}
/*** 从配方正文里认出用到的组件 id(按词边界匹配,避免 toast 命中 toastError) ***/
const recipeComponents = (r) =>
compIds.filter((id) => new RegExp(`\\b${id}\\b`).test(`${r.code}\n${r.text}`)).sort();
const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
/*** 执行 MCP 工具调用 ***/
function callTool(name, args) {
if (name === 'list_components') {
return packages.map((p) => ({ name: p.name, version: p.version, type: p.type, description: p.description }));
}
if (name === 'get_component') {
const p = packages.find((x) => x.name === args.name || x.name === `@ui-arch/${args.name}`);
if (p) return p;
const hit = collectComponents(packages).find((x) => x.comp.id === args.name); // 退化到组件级查询
if (!hit) throw new Error(`not found: ${args.name}`);
return { package: hit.pkg.name, ...hit.comp, recipes: recipesFor(hit.comp.id) };
}
if (name === 'search_components') {
const q = String(args.query ?? '').toLowerCase();
return 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 }));
}
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,
result: { content: [{ type: 'text', text: 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;
}
/*** 皮肤包令牌引用校验: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} 个文件`);
}
/*** 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
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') done.add(row.id);
}
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 (!/^## 通用约定/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;
}
/*** 清单一致性校验:组件文档块的 id / props 说明 / demo 锚点必须与代码和演示产物对齐 ***/
function cmdVerify() {
const issues = [];
const pkgs = collectManifests(); // 一次收集,供组件校验与 README 区块校验共用
const demoPath = path.join(ROOT, 'apps', 'demo', 'main.ts'); // 演示区块定义在源码里(产物是打包后的 JS)
const demoSource = fs.existsSync(demoPath) ? fs.readFileSync(demoPath, 'utf8') : '';
const anchors = new Set(demoSource.match(/sec-[a-z0-9-]+/g) ?? []); // 已定义的区块锚点
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(...checkTokenRefs(pkg, tokenVars));
issues.push(...checkPropsImpl(pkg, comps));
issues.push(...checkPropsDoc(pkg, comps));
}
if (comps.length && !exports.length) issues.push(`${pkg.name}: docs.components 有 ${comps.length} 项但 api.exports 为空`);
const seen = new Set(); // 组件 id 去重(同一清单内不允许重复)
for (const c of comps) {
const tag = `${pkg.name}#${c.id ?? '?'}`;
if (!c.id || !c.title) issues.push(`${tag}: 缺少 id 或 title`);
if (seen.has(c.id)) issues.push(`${tag}: id 重复`);
seen.add(c.id);
if (!c.example) issues.push(`${tag}: 缺少 example`);
if ((c.steps ?? []).length < 3) issues.push(`${tag}: steps 少于 3 条`);
if (!exports.some((e) => e.startsWith(`${c.id}(`) || e.startsWith(`${c.id}.`))) {
issues.push(`${tag}: api.exports 中没有以「${c.id}(」或「${c.id}.」开头的条目`);
}
for (const prop of c.props ?? []) {
if (!prop.n || !prop.t || !prop.d) issues.push(`${tag}: props 条目 ${prop.n ?? '?'} 缺 n/t/d`);
}
if (c.demo && demoSource && !anchors.has(c.demo)) {
issues.push(`${tag}: demo 锚点 ${c.demo} 在 apps/demo/main.ts 中不存在(缺对应 section)`);
}
if (demoSource && !demoSource.includes(`${c.id}(`) && !demoSource.includes(`${c.id}.`)) {
issues.push(`${tag}: apps/demo/main.ts 未实际调用 ${c.id}()(只有 section 锚点不算演示)`);
}
}
}
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));
checkLlmsFull(pkgs, issues);
issues.push(...checkIterations());
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 ↔ 入参接口双向一致,皮肤无硬编码颜色与未定义令牌,README 组件清单与分组同步,backlog ↔ 留痕一致,组合配方结构完整,MCP 工具清单三处一致)');
}
/*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/
function cmdCommitLint(args) {
// 输入来源:文件路径(commit-msg 钩子传参)或参数拼接
let msg = '';
if (args[0] && fs.existsSync(args[0])) msg = fs.readFileSync(args[0], 'utf8');
else msg = args.join(' ');
if (!msg.trim()) { console.error('commit-lint: 提交信息为空'); process.exit(1); }
const head = msg.split('\n')[0].trimEnd();
// git 自动生成的合并/回滚信息直接放行
if (/^(Merge |Revert |fixup!|squash!)/.test(head)) return console.log('commit-lint OK (git 自动信息,跳过校验)');
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore', 'iter'];
const m = head.match(/^(\w+)(?:\(([^)]*)\))?: (.+)$/);
if (!m) { console.error('commit-lint: 首行须为 <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;
}
/*** 迭代留痕校验:.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)`);
}
// ---- 入口 ----
const [cmd, ...args] = process.argv.slice(2);
switch (cmd) {
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 '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 'mcp':
cmdMcp();
break;
default:
console.log('usage: ua <index|list|search <kw>|info <name>|docs|tokens [--json]|style|sync-counts|verify|commit-lint|lint-iterations|changelog|mcp>');
process.exit(cmd ? 1 : 0);
}