Files
UI/packages/tools/cli/bin/ua.js
T
2026-10-23 18:48:34 +08:00

768 lines
33 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';
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');
/*** 汇总各包 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;
}
/*** 聚合清单,生成 .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,
};
fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n');
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('');
}
fs.writeFileSync(LLMS_FILE, lines.join('\n'));
console.log(`indexed ${packages.length} package(s) -> .ua/index.json, llms.txt`);
}
/*** 读取注册表索引;缺失则报错退出 ***/
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'));
}
/*** 列出全部已注册包 ***/
function cmdList() {
const { packages } = loadIndex();
if (!packages.length) return console.log('(no packages indexed)');
for (const p of packages) {
console.log(`${p.name} v${p.version} [${p.type}] ${p.description}`);
}
}
/*** 输出单包完整元数据 ***/
function cmdInfo(name) {
const { packages } = loadIndex();
const p = packages.find((x) => x.name === name || x.name === `@ui-arch/${name}`);
if (!p) {
console.error(`not found: ${name}`);
process.exit(1);
}
console.log(JSON.stringify(p, null, 2));
}
// ---- 静态文档站生成 ----
/*** 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}
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" placeholder="搜索组件、参数、API…" autocomplete="off"></header>
<nav class="pkgnav" id="nav"></nav>
<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>';return}
app.innerHTML=quickstart()+list.map(pkgCard).join('')+(list.some(function(p){return p.name.indexOf('tokens')>=0})?'':tokensCard());
}
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>
`;
}
/*** 渲染 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('---', '');
}
if (tokens?.color) {
lines.push('## 设计令牌(--ua-*)', '');
for (const [k, v] of Object.entries(tokens.color)) {
const light = typeof v === 'string' ? v : v.light;
const dark = typeof v === 'string' ? v : v.dark;
lines.push(`- --ua-color-${k}: ${light}${dark !== light ? `(暗色 ${dark})` : ''}`);
}
for (const [g, vals] of Object.entries(tokens)) {
if (g === 'color' || g === '$schema') continue;
lines.push('', `### ${g}`, '');
for (const [k, v] of Object.entries(vals)) lines.push(`- --ua-${g}-${k}: ${v}`);
}
lines.push('');
}
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'));
fs.writeFileSync(LLMS_FULL, renderAgentDocs(index, tokens));
console.log(
`docs generated -> apps/docs/index.html + demo.html, llms-full.txt (${index.packages.length} packages)`,
);
}
// ---- 内置 stdio MCP server(JSON-RPC 2.0,按行分帧) ----
/*** 启动 MCP server:向 Agent 暴露 list / get / search 三个查询工具 ***/
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 full metadata (API, usage, conventions) of one component',
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'],
},
},
];
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) throw new Error(`not found: ${args.name}`);
return p;
}
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 }));
}
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('/***')) {
issues.push(`${file}:${i + 1} 导出 ${decl[1]} 缺少 /*** 作用 ***/ 注释`);
}
}
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;
}
/*** 全仓风格检查:有违规退出码 1,全绿输出 OK ***/
function cmdStyle() {
const files = collectSourceFiles();
const issues = [];
for (const f of files) {
issues.push(...styleCheckFile(f, fs.readFileSync(f, 'utf8')));
}
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)`);
}
/*** 皮肤包硬编码颜色检查:#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;
}
/*** 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 listed = new Set(block[1].split('·').map((s) => s.trim()).filter(Boolean));
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}(清单里没有)`);
return issues;
}
/*** 组件数漂移校验:AGENTS.md / ARCHITECTURE.md 中的「N 个组件」必须与 skin-dom 清单实际总数一致 ***/
function checkCountDocs(actual) {
const issues = [];
const re = /(\d+)\s*个(?:\s*DOM\s*)?组件/;
for (const name of ['AGENTS.md', 'ARCHITECTURE.md']) {
const file = path.join(ROOT, name);
if (!fs.existsSync(file)) continue; // 卫语句:文档缺失不校验
fs.readFileSync(file, 'utf8').split(/\r?\n/).forEach((line, i) => {
const m = line.match(re);
if (m && Number(m[1]) !== actual) {
issues.push(`${name}:${i + 1} 组件数「${m[1]}」与清单实际 ${actual} 不一致,请同步`);
}
});
}
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) ?? []); // 已定义的区块锚点
if (!demoSource) console.log('verify: 未找到 apps/demo/main.ts,跳过 demo 锚点校验');
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));
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)`);
}
}
}
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));
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 一致,皮肤无硬编码颜色,README 组件清单同步)');
}
/*** 校验 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})` : ''})`);
}
/*** 迭代留痕校验:.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 不能为空`);
});
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();
break;
case 'info':
if (!args[0]) {
console.error('usage: ua info <package-name>');
process.exit(1);
}
cmdInfo(args[0]);
break;
case 'docs':
cmdDocs();
break;
case 'style':
cmdStyle();
break;
case 'verify':
cmdVerify();
break;
case 'commit-lint':
cmdCommitLint(args);
break;
case 'lint-iterations':
cmdLintIterations();
break;
case 'mcp':
cmdMcp();
break;
default:
console.log('usage: ua <index|list|info <name>|docs|style|verify|commit-lint|lint-iterations|mcp>');
process.exit(cmd ? 1 : 0);
}