【本次核心 · 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 个 —— 历史从未跟踪且属构建产物,保持现状。
270 lines
12 KiB
JavaScript
270 lines
12 KiB
JavaScript
#!/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);
|