Files
aurora-admin/tools/verify-package-import.mjs
T
aurora-admin eb25feedaf feat(S6-P21): 组件族参数化(13族/44成员/导航族15文件同源/79→48概念组件)
【本次核心 · S6-P21】
- 族层数据:families.json + 44 份契约注入 family/familyRole/familyParams;
  data.json / data.js / site/details 同步。13 族 / 44 成员 / 35 独立 → 概念组件 79→48。
- 79 个 slug 全保留、集合逐一不变(铁律 5 对外承诺未破);frameworks 仍 395 文件、薄壳仍 79。
- 归族判据为契约中可核对字段(semanticTypeCandidates 重叠 / anatomy 为同一骨架子集 /
  变体维度同构 / doNotInvent 显式从属声明),每族 mergeBasis 写明依据,不按名字猜。
- 实现层合并(导航族端到端切片):tools/gen-family-impl.mjs 从 5 端模板生成
  TopMenu / SideMenu / MixedNavigation 共 15 文件,参数 direction=top|side|mixed;
  三份 CSS md5 完全相同 = 一份样式表服务三个组件。
- 新增 tools/gen-families.mjs、tools/gen-family-impl.mjs、tools/verify-families.mjs、
  tools/lib/family-model.mjs、tools/lib/family-impl/nav-menu/*.tpl。

【同时清掉此前已完成但未提交的批次】
生成物(data.json / data.js / site/sources / site/components 薄壳 / sitemap.xml / tests 报告)
跨阶段交织,无法拆成互相自洽的多个提交,故按既有批量风格合并提交:
- Package:三端可 import(S5-P18)+ 发布到私有 npm 源
- Docs site:导航语言改下拉(S5-P19)、详情页代码块默认展开、中英切换完整性
- Security:生产部署链审计修复(2026-09-19)+ 线上部署
- Theme modes 日间/夜间/自动;S1-P4 data.js 瘦身;S2-P5 暗色;S2-P6 跨端一致性;
  S2-P7 行为断言;S2-P9 FAQ;S3-P8 RTL;S3-P9 契约缺口解释层;S4-P12 发布流程
- 补入 tools/pack-deploy.mjs、run-site-smoke.mjs、verify-*.mjs,.dockerignore、
  安全审计修复与待决策项.md

【验收】
- node tools/verify-families.mjs → OK: 族层端到端一致(13 族 / 44 成员 / 79 组件不变 / 395 文件不变)
- node tools/verify-cross-platform.mjs → 79/79 identical(HEAD 基线 high 44)
- node tools/run-regression.mjs → 100%(79/79 页,1017/1017 断言,N/A 34),连跑 8 次一致,0 超时
- 逐页实测:topmenu / sidemenu / mixednavigation 各 13/13,帧内 direction 参数正确,0 JS 错误
- 零运行时依赖 OK;build-site.ps1 ASCII-only OK

【未纳入】site/components/<slug>/ 平台薄壳 316 个 —— 历史从未跟踪且属构建产物,保持现状。
2026-09-20 03:32:31 +08:00

270 lines
12 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
/**
* verify-package-import.mjs — 验证 dist/ 真的能被 import(不是"文件存在"式检查)
*
* 背景:2026-09-19 实测发现 dist/ 只发 CSS 与演示 HTML,package.json 的 main
* 指向一个 CSS 文件,**没有任何可 import 的组件**;同时 frameworks/ 里存在
* 「无法编译」的组件(RangeQuickPicker 给 const 重新赋值、CodeInput 的 emit 遮蔽),
* 而这两类问题都能通过"真编译 + 真挂载"抓出来。
*
* 检查分两级:
* 1) 结构级(零依赖,始终执行):入口文件存在、导出语句与目标文件一一对应、
* 所有相对 import/`<style src>` 可解析(断链会让用户"import 成功但样式全丢")
* 2) 编译级(需要可选依赖,缺失则 SKIP):用 esbuild + @vue/compiler-sfc
* 真编译 79 个 .vue / .jsx,并用 vue/server-renderer 与 react-dom/server
* 真渲染 AaButton 断言 DOM 输出
*
* 启用编译级检查(可选依赖,不入 package.json,避免拖累"零运行时依赖"):
* npm i -D esbuild vue @vue/compiler-sfc react react-dom
* 或指向任意已装好这些依赖的目录(保持仓库 node_modules 干净):
* AA_VERIFY_DEPS_DIR=.zcode/verify-import node tools/verify-package-import.mjs
*
* 用法:node tools/verify-package-import.mjs
* 退出码:0 = 通过(含 SKIP);1 = 有失败项
*/
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');
const DIST = path.join(ROOT, 'dist');
/* OUT 在下方按 AA_VERIFY_DEPS_DIR 决定(需与可选依赖同处一棵 node_modules 树) */
const results = [];
const ok = (name, extra = '') => results.push(['OK ', name, extra]);
const fail = (name, extra = '') => results.push(['FAIL', name, extra]);
const skip = (name, extra = '') => results.push(['SKIP', name, extra]);
/* ---------- 0. 前置:dist 是否已构建 ---------- */
if (!fs.existsSync(DIST)) {
console.error('[verify-package-import] 未找到 dist/,请先运行:node tools/build-dist.mjs');
process.exit(1);
}
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
/* ---------- 1. 结构级:入口与断链 ---------- */
const ENDS = ['react', 'vue3', 'vue2'];
for (const end of ENDS) {
const dir = path.join(DIST, end);
const entry = path.join(dir, 'index.js');
if (!fs.existsSync(entry)) {
fail(`dist/${end}/index.js 存在`, '入口缺失,无法 import');
continue;
}
const src = fs.readFileSync(entry, 'utf8');
const exportLines = src.split('\n').filter((l) => l.trim().startsWith('export {'));
ok(`dist/${end} 入口导出语句数`, String(exportLines.length));
if (exportLines.length !== 79) fail(`dist/${end} 应导出 79 个组件`, String(exportLines.length));
const missing = [];
for (const l of exportLines) {
const m = l.match(/from '\.\/(.+)';/);
if (m && !fs.existsSync(path.join(dir, m[1]))) missing.push(m[1]);
}
if (missing.length) fail(`dist/${end} 入口目标全部存在`, `断链 ${missing.length}:${missing.slice(0, 3).join(', ')}`);
else ok(`dist/${end} 入口目标全部存在`);
}
/* 断链检查:相对 import / @import / url() / <style src>
注意:先剥离注释——组件源码的文档注释里有用法示例(如 `from './Button'`),
那不是真实依赖,误报会让这个检查失去意义。 */
for (const end of ENDS) {
const dir = path.join(DIST, end);
if (!fs.existsSync(dir)) continue;
const broken = [];
for (const f of fs.readdirSync(dir)) {
if (!/\.(jsx|vue|js|css)$/.test(f)) continue;
const raw = fs.readFileSync(path.join(dir, f), 'utf8');
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
const specs = [];
for (const m of src.matchAll(/(?:import\s+['"]|from\s+['"]|@import\s+['"]|url\(\s*['"]?)(\.[^'")]+)/g)) {
specs.push(m[1]);
}
for (const s of specs) {
const t = path.join(dir, s);
if (!fs.existsSync(t) && !fs.existsSync(t + '.jsx') && !fs.existsSync(t + '.css')) {
broken.push(`${f} -> ${s}`);
}
}
}
if (broken.length) fail(`dist/${end} 相对引用无断链`, `${broken.length} 处:${broken.slice(0, 3).join(' | ')}`);
else ok(`dist/${end} 相对引用无断链`);
}
/* 用户实际会踩的坑:import 组件但样式丢失。
抽查 3 个用 <style src> 的组件,确认 CSS 随包发出。 */
{
const probe = ['CodeInput', 'AlertModal', 'RangeQuickPicker'];
const bad = [];
for (const p of probe) {
for (const end of ['vue3', 'vue2']) {
const vue = path.join(DIST, end, `${p}.vue`);
if (!fs.existsSync(vue)) continue;
const src = fs.readFileSync(vue, 'utf8');
const m = src.match(/<style[^>]*src=['"]\.\/([^'"]+)['"]/);
if (m && !fs.existsSync(path.join(DIST, end, m[1]))) bad.push(`${end}/${p}.vue -> ${m[1]}`);
}
}
if (bad.length) fail('SFC 外部样式随包发布', bad.join(', '));
else ok('SFC 外部样式随包发布', `抽查 ${probe.length} 个组件`);
}
/* ---------- 2. package.json 入口映射 ---------- */
{
const exp = pkg.exports || {};
const need = ['react', 'vue3', 'vue2'];
const miss = need.filter((k) => !exp[`./${k}`]);
if (miss.length) fail('package.json exports 含三端入口', miss.join(', '));
else ok('package.json exports 含三端入口');
const badTarget = [];
for (const [k, v] of Object.entries(exp)) {
if (k.includes('*') || k === './package.json') continue;
const target = typeof v === 'string' ? v : v.default;
if (target && !fs.existsSync(path.join(ROOT, target))) badTarget.push(`${k} -> ${target}`);
}
if (badTarget.length) fail('exports 目标文件存在', badTarget.join(', '));
else ok('exports 目标文件存在');
if (pkg.dependencies && Object.keys(pkg.dependencies).length) {
fail('零运行时依赖', Object.keys(pkg.dependencies).join(', '));
} else {
ok('零运行时依赖');
}
const peers = Object.keys(pkg.peerDependencies || {});
ok('peerDependencies 声明', peers.length ? peers.join(', ') : '(无)');
}
/* ---------- 3. 编译级:真编译 + 真挂载(可选依赖)---------- */
/* 可选依赖可从 AA_VERIFY_DEPS_DIR 指定目录解析,避免为了跑编译级检查
往仓库 node_modules 里装一堆东西(--no-save 只保护 package.json,
并不保护 node_modules)。 */
const DEPS_DIR = process.env.AA_VERIFY_DEPS_DIR
? path.resolve(ROOT, process.env.AA_VERIFY_DEPS_DIR)
: ROOT;
const depsRequire = DEPS_DIR === ROOT ? require : createRequire(path.join(DEPS_DIR, 'noop.js'));
/* bundle 里 vue / react 是 external,运行时仍需解析到它们。
把产物输出到依赖目录下,import 时才能顺着 node_modules 找到(否则报
"Cannot find package 'vue' imported from ...")。 */
const OUT = DEPS_DIR === ROOT
? path.join(ROOT, '.tmp', 'verify-package-import')
: path.join(DEPS_DIR, '.verify-out');
function tryRequire(name) {
try { return depsRequire(name); } catch { return null; }
}
const esbuild = tryRequire('esbuild');
const vueSfc = tryRequire('@vue/compiler-sfc');
const vue = tryRequire('vue');
const vueServerRenderer = tryRequire('vue/server-renderer');
const react = tryRequire('react');
const reactDomServer = tryRequire('react-dom/server');
const canVue = esbuild && vueSfc && vue && vueServerRenderer;
const canReact = esbuild && react && reactDomServer;
if (!canVue && !canReact) {
skip('编译级检查(真编译 + 真挂载)', '未安装可选依赖;启用:npm i -D esbuild vue @vue/compiler-sfc react react-dom');
} else {
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const asUrl = (p) => pathToFileURL(p).href;
const vuePlugin = {
name: 'vue-sfc',
setup(build) {
build.onLoad({ filter: /\.vue$/ }, (args) => {
const source = fs.readFileSync(args.path, 'utf8');
const { descriptor, errors } = vueSfc.parse(source, { filename: args.path });
if (errors.length) throw new Error('SFC parse: ' + errors[0].message);
const id = Buffer.from(args.path).toString('hex').slice(0, 8);
const script = vueSfc.compileScript(descriptor, { id });
const template = descriptor.template
? vueSfc.compileTemplate({
source: descriptor.template.content,
filename: args.path,
id,
scoped: descriptor.styles.some((s) => s.scoped),
compilerOptions: { bindingMetadata: script.bindings },
})
: { code: 'export function render(){return null}' };
if (template.errors && template.errors.length) throw new Error('template: ' + template.errors[0].message);
return {
contents: `
${script.content.replace(/export\s+default/, 'const __sfc_main =')}
${template.code.replace(/export\s+function\s+render/, 'function __sfc_render')}
__sfc_main.render = __sfc_render;
export default __sfc_main;
`,
loader: 'js',
resolveDir: path.dirname(args.path),
};
});
},
};
if (canVue) {
try {
await esbuild.build({
entryPoints: [path.join(DIST, 'vue3', 'index.js')],
bundle: true, format: 'esm',
outfile: path.join(OUT, 'vue3-bundle.mjs'),
plugins: [vuePlugin], external: ['vue'], logLevel: 'silent',
});
ok('dist/vue3 入口可编译(79 个 .vue)', fs.statSync(path.join(OUT, 'vue3-bundle.mjs')).size + ' bytes');
const mod = await import(asUrl(path.join(OUT, 'vue3-bundle.mjs')));
const names = Object.keys(mod).filter((k) => k.startsWith('Aa'));
if (names.length === 79) ok('dist/vue3 导出组件数', '79');
else fail('dist/vue3 应导出 79 个组件', String(names.length));
const { createSSRApp, h } = vue;
const { renderToString } = vueServerRenderer;
const html = await renderToString(createSSRApp({ render: () => h(mod.AaButton, { type: 'primary', text: '保存' }) }));
if (/class="btn[^"]*btn-primary/.test(html) && html.includes('保存')) ok('AaButton 真实渲染', html.slice(0, 80));
else fail('AaButton 渲染结果不符', html.slice(0, 160));
} catch (e) {
fail('dist/vue3 编译/渲染', String(e.message).slice(0, 220));
}
} else {
skip('dist/vue3 编译级检查', '缺 esbuild / vue / @vue/compiler-sfc');
}
if (canReact) {
try {
await esbuild.build({
entryPoints: [path.join(DIST, 'react', 'index.js')],
bundle: true, format: 'esm',
outfile: path.join(OUT, 'react-bundle.mjs'),
loader: { '.jsx': 'jsx', '.css': 'css' },
external: ['react', 'react-dom'], logLevel: 'silent',
});
ok('dist/react 入口可编译(79 个 .jsx + CSS)', fs.statSync(path.join(OUT, 'react-bundle.mjs')).size + ' bytes');
const mod = await import(asUrl(path.join(OUT, 'react-bundle.mjs')));
const names = Object.keys(mod).filter((k) => k.startsWith('Aa'));
if (names.length === 79) ok('dist/react 导出组件数', '79');
else fail('dist/react 应导出 79 个组件', String(names.length));
const html = reactDomServer.renderToStaticMarkup(react.createElement(mod.AaButton, { type: 'primary' }, '保存'));
if (/class="btn[^"]*btn-primary/.test(html) && html.includes('保存')) ok('AaButton(React)真实渲染', html.slice(0, 80));
else fail('AaButton(React)渲染结果不符', html.slice(0, 160));
} catch (e) {
fail('dist/react 编译/渲染', String(e.message).slice(0, 220));
}
} else {
skip('dist/react 编译级检查', '缺 esbuild / react / react-dom');
}
}
/* ---------- 输出 ---------- */
console.log('\n===== dist 可 import 性验证 =====');
for (const [tag, name, extra] of results) console.log(`${tag} ${name}${extra ? ' — ' + extra : ''}`);
const failed = results.filter((r) => r[0] === 'FAIL').length;
const skipped = results.filter((r) => r[0] === 'SKIP').length;
console.log(`\n${failed === 0 ? 'PASS' : failed + ' FAILED'} — ${results.length} checks${skipped ? `(${skipped} 项 SKIP)` : ''}`);
process.exit(failed === 0 ? 0 : 1);