iter(g26): verify 增加令牌引用校验并统一令牌来源
This commit is contained in:
@@ -327,20 +327,11 @@ function renderAgentDocs(index, tokens) {
|
||||
}
|
||||
lines.push('---', '');
|
||||
}
|
||||
if (tokens?.color) {
|
||||
const tokenList = loadTokenList(); // 令牌唯一来源:tokens.css(含字号、间距复合值)
|
||||
if (tokenList.length) {
|
||||
lines.push('## 设计令牌(--ua-*)', '');
|
||||
const kebab = (s) => s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); // textMuted → text-muted,与 ui.css 实际变量名一致
|
||||
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-${kebab(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}-${kebab(k)}: ${v}`);
|
||||
}
|
||||
lines.push('');
|
||||
for (const t of tokenList) lines.push(`- ${t.cssVar}: ${t.value}${t.dark && t.dark !== t.value ? `(暗色 ${t.dark})` : ''}`);
|
||||
lines.push('', '皮肤包只允许消费这些变量:写死 #hex 会被 verify 拦截,拼错变量名会被令牌引用校验拦截。', '');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -368,22 +359,22 @@ function cmdDocs() {
|
||||
|
||||
// ---- 令牌查询:CLI 与 MCP 共用同一份拍平结果 ----
|
||||
|
||||
/*** 读取 tokens.json 拍平为 CSS 变量清单(颜色区分亮/暗两态,其余单值;名字统一 kebab-case 与 ui.css 对齐) ***/
|
||||
/*** 读取 tokens.css 拍平为令牌清单(:root 为基准值,[data-theme='dark'] 覆盖为暗色值;名字即 ui.css 实际变量名) ***/
|
||||
function loadTokenList() {
|
||||
const p = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.json');
|
||||
const p = path.join(ROOT, 'packages', 'design', 'tokens', 'tokens.css');
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const tokens = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
const kebab = (s) => s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); // camelCase 键转 kebab-case
|
||||
const out = [];
|
||||
for (const [k, v] of Object.entries(tokens.color ?? {})) {
|
||||
if (typeof v === 'string') out.push({ cssVar: `--ua-color-${kebab(k)}`, value: v });
|
||||
else out.push({ cssVar: `--ua-color-${kebab(k)}`, value: v.light, dark: v.dark });
|
||||
}
|
||||
for (const [group, vals] of Object.entries(tokens)) {
|
||||
if (group === 'color' || group.startsWith('$')) continue;
|
||||
for (const [k, v] of Object.entries(vals)) out.push({ cssVar: `--ua-${group}-${kebab(k)}`, value: String(v) });
|
||||
}
|
||||
return out;
|
||||
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 输出结构化数组供脚本消费 ***/
|
||||
@@ -636,6 +627,31 @@ function checkSkinCss(pkg) {
|
||||
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 = [];
|
||||
@@ -754,12 +770,16 @@ function cmdVerify() {
|
||||
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) 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 (pkg.type === 'skin-dom') {
|
||||
issues.push(...checkSkinCss(pkg));
|
||||
issues.push(...checkTokenRefs(pkg, tokenVars));
|
||||
}
|
||||
if (comps.length && !exports.length) issues.push(`${pkg.name}: docs.components 有 ${comps.length} 项但 api.exports 为空`);
|
||||
|
||||
const seen = new Set(); // 组件 id 去重(同一清单内不允许重复)
|
||||
@@ -795,7 +815,7 @@ function cmdVerify() {
|
||||
console.error(`${issues.length} violation(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('verify OK (manifest ↔ docs ↔ demo 一致,皮肤无硬编码颜色,README 组件清单同步,backlog ↔ 留痕一致)');
|
||||
console.log('verify OK (manifest ↔ docs ↔ demo 一致,皮肤无硬编码颜色与未定义令牌,README 组件清单同步,backlog ↔ 留痕一致)');
|
||||
}
|
||||
|
||||
/*** 校验 git 提交信息是否符合 COMMITTING.md 规范(参数为 message 文件路径或直接 message 文本),违规退出码 1 ***/
|
||||
|
||||
Reference in New Issue
Block a user