- core 协议:Plugin 接口/注册器(重名拒绝/生命周期正序逆序/dispose幂等/错误隔离不连坐)+受控面 PluginContext(禁直透 littlejsengine)——前任agent核心设计17测绿收编(死于收尾的工具调用解析失败,续作字节级未碰) - _example 空插件样例五件全(克隆母本)+PLUGIN-TEMPLATE 六节规格+manifest schema+零依赖校验器(负例自检逮6违规) - scripts:esbuild锁参build/增量法size/test.sh遍历;browser-evidence harness纯计算口径冻结(FNV-1a/直方图/几何,CDP四函数=集成段占位) - 22/22 测全绿(RESULT.txt 留档);ESM JS+JSDoc+手写d.ts 零工具链纪律全程 - rc 状态:待 Gate0.1 受控面补丁(input/audio+per-plugin派生RNG+useContext守卫)后冻结 core-protocol-v0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
8.5 KiB
JavaScript
185 lines
8.5 KiB
JavaScript
/**
|
||
* scripts/size.mjs — 发行版字节预算「增量法」测量器(core-protocol-v0)
|
||
*
|
||
* 口径(执行版 §5,与 channel-spike 同策略,写死不得擅改):
|
||
* - 打包:复用 build.mjs 的锁定参数(bundle+minify+iife+es2019+sourcemap=false);
|
||
* - gz:**zlib.gzipSync(buf, { level: 9 })**(node 内置;等价 `gzip -9`,但纯内置免外部进程);
|
||
* - **增量法**:基线壳 → +core → 逐插件叠加,每步实测「当前累计 bundle 的 raw/gz」,
|
||
* 相邻两步差值 = 该新增件的「增量 raw/gz」(树摇后的真实净增,非孤立打包值);
|
||
* - 输出 `SIZES.md`:分项 raw/gz 双列 + 命令注记 + 各插件 manifest 的 gzBudgetBytes 对照(超配额标 ⚠️)。
|
||
*
|
||
* ⚠️ 运行环境:真实打包需 esbuild → **仅集成段(mini-desktop)跑**;本机无 esbuild 会报指引。
|
||
* gz 量算(zlib)逻辑本机可独立自检:`node scripts/size.mjs --selftest`(不需 esbuild)。
|
||
*
|
||
* 用法(集成段):node scripts/size.mjs → 生成 game-runtime/SIZES.md
|
||
* 本机自检 :node scripts/size.mjs --selftest
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import { gzipSync } from 'node:zlib';
|
||
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { LOCKED_BUILD_OPTIONS } from './build.mjs';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = resolve(__dirname, '..');
|
||
const PLUGINS_DIR = join(ROOT, 'src', 'plugins');
|
||
const CORE_ENTRY = join(ROOT, 'src', 'core', 'plugin.js');
|
||
|
||
/**
|
||
* 量算一段 buffer 的 raw 与 gz(level 9)字节数。这是 SIZES 的最底层口径,纯内置可独立测。
|
||
* @param {Buffer|Uint8Array|string} content
|
||
* @returns {{ raw: number, gz: number }}
|
||
*/
|
||
export function measureBytes(content) {
|
||
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||
return { raw: buf.length, gz: gzipSync(buf, { level: 9 }).length };
|
||
}
|
||
|
||
/**
|
||
* 用锁定参数把一组入口打成「单 iife bundle」并返回其字节数(增量法的一档)。
|
||
* 集成段用;需 esbuild。多入口时用一个聚合虚拟入口把它们 import 到一起,量整包累计大小。
|
||
* @param {string[]} entries 该档要叠加进来的入口文件(绝对路径)
|
||
* @param {object} esbuild 已 import 的 esbuild 模块
|
||
* @returns {Promise<{ raw:number, gz:number }>}
|
||
*/
|
||
async function bundleSize(entries, esbuild) {
|
||
if (entries.length === 0) return { raw: 0, gz: 0 };
|
||
// 聚合入口:把多个文件 import 进一个临时入口,模拟「整包累计」。
|
||
const tmp = mkdtempSync(join(tmpdir(), 'rt-size-'));
|
||
try {
|
||
const aggregate = join(tmp, 'aggregate.js');
|
||
const body = entries.map((e, i) => `import * as m${i} from ${JSON.stringify(e)};`).join('\n')
|
||
+ '\n// 防树摇整体消除:把各模块挂到全局副作用点。\n'
|
||
+ 'globalThis.__rt_keep = [' + entries.map((_, i) => `m${i}`).join(', ') + '];\n';
|
||
writeFileSync(aggregate, body);
|
||
const out = await esbuild.build({
|
||
...LOCKED_BUILD_OPTIONS,
|
||
entryPoints: [aggregate],
|
||
bundle: true,
|
||
write: false, // 不落盘,直接拿产物 buffer 量字节
|
||
globalName: '__RT_SIZE_PROBE',
|
||
});
|
||
const code = out.outputFiles[0].contents; // Uint8Array
|
||
return measureBytes(Buffer.from(code));
|
||
} finally {
|
||
rmSync(tmp, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
/** 扫描 src/plugins/<*>(排除 _example 可选)→ 返回 [{name, entry, budget}],按 name 排序稳定。 */
|
||
function collectPlugins() {
|
||
const list = [];
|
||
if (!existsSync(PLUGINS_DIR)) return list;
|
||
for (const name of readdirSync(PLUGINS_DIR).sort()) {
|
||
const dir = join(PLUGINS_DIR, name);
|
||
const mf = join(dir, 'manifest.json');
|
||
if (!existsSync(mf)) continue;
|
||
const manifest = JSON.parse(readFileSync(mf, 'utf8'));
|
||
const impl = join(dir, (manifest.exports && manifest.exports.impl) || 'impl.js');
|
||
list.push({ name: manifest.name, dir: name, entry: impl, budget: manifest.gzBudgetBytes });
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/** 生成 SIZES.md 文本。 */
|
||
function renderSizesMd(rows, baseline) {
|
||
const lines = [];
|
||
lines.push('# SIZES.md —— 发行版字节预算实测(增量法,core-protocol-v0)');
|
||
lines.push('');
|
||
lines.push('> 由 `scripts/size.mjs` 在集成段(mini-desktop)生成。**勿手改**,改源后重跑覆盖。');
|
||
lines.push('> 口径:esbuild `bundle+minify+format=iife+target=es2019+sourcemap=false`;');
|
||
lines.push('> gz = `zlib.gzipSync(buf,{level:9})`(等价 `gzip -9`);**增量法** = 基线壳→+core→逐插件叠加,差值为各件净增。');
|
||
lines.push('');
|
||
lines.push('| 叠加项 | 累计 raw | 累计 gz | 增量 raw | 增量 gz | gz 预算 | 判定 |');
|
||
lines.push('|---|--:|--:|--:|--:|--:|:--|');
|
||
lines.push(`| 基线壳 | ${baseline.raw} | ${baseline.gz} | — | — | — | 基准 |`);
|
||
let totalGz = 0;
|
||
for (const r of rows) {
|
||
const verdict = r.budget == null ? '—'
|
||
: r.deltaGz <= r.budget ? `✔ ≤${r.budget}` : `⚠️ 超 ${r.deltaGz - r.budget}B`;
|
||
if (r.budget != null) totalGz += r.deltaGz;
|
||
lines.push(`| ${r.label} | ${r.cumRaw} | ${r.cumGz} | ${r.deltaRaw} | ${r.deltaGz} | ${r.budget ?? '—'} | ${verdict} |`);
|
||
}
|
||
lines.push('');
|
||
lines.push(`合计插件 gz 增量 ≈ **${totalGz}B**(自律线 60K;超 60K 须创始人显式放行,执行版 §5)。`);
|
||
lines.push('');
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
/** --selftest:仅验 zlib gz 量算口径(不需 esbuild,本机可跑留证)。 */
|
||
function selftest() {
|
||
const cases = ['', 'a', 'hello world', 'x'.repeat(1000)];
|
||
console.log('[size --selftest] zlib gzipSync(level:9) 量算自检:');
|
||
for (const s of cases) {
|
||
const m = measureBytes(s);
|
||
// 基本不变式:raw=字节长度;gz>0(gzip 头即非零);gz 对高度可压缩串应远小于 raw。
|
||
if (m.raw !== Buffer.from(s).length) throw new Error('raw 口径错');
|
||
if (m.gz <= 0) throw new Error('gz 应 >0');
|
||
console.log(` len=${String(m.raw).padStart(4)} raw=${m.raw} gz=${m.gz}`);
|
||
}
|
||
// 可压缩性断言:1000 个相同字符 gz 必远小于 raw。
|
||
const big = measureBytes('x'.repeat(1000));
|
||
if (!(big.gz < big.raw / 5)) throw new Error('高度可压缩串 gz 未显著小于 raw,口径可疑');
|
||
console.log('[size --selftest] OK:gz 量算口径正确(高压缩串 gz << raw)。');
|
||
}
|
||
|
||
// ── 主流程 ──────────────────────────────────────────────────────────────────
|
||
async function main() {
|
||
if (process.argv.includes('--selftest')) {
|
||
selftest();
|
||
return;
|
||
}
|
||
|
||
let esbuild;
|
||
try {
|
||
esbuild = await import('esbuild');
|
||
} catch {
|
||
console.error(
|
||
'[size] 未找到 esbuild —— 增量法实测仅在集成段(mini-desktop)跑。\n' +
|
||
' 请在集成段执行:npm i -D esbuild,再重跑生成 SIZES.md。\n' +
|
||
' 本机仅可跑 gz 量算自检:node scripts/size.mjs --selftest'
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
// 基线壳:本波无独立「空壳」入口,以「core 之前」为 0 基线(增量法起点)。
|
||
// 若后续有引擎壳入口,可在此把 baseline 换成壳的实测值(口径不变)。
|
||
const baseline = { raw: 0, gz: 0 };
|
||
|
||
// 第一档:+core
|
||
const coreSize = await bundleSize([CORE_ENTRY], esbuild);
|
||
const rows = [];
|
||
rows.push({
|
||
label: '+core', cumRaw: coreSize.raw, cumGz: coreSize.gz,
|
||
deltaRaw: coreSize.raw - baseline.raw, deltaGz: coreSize.gz - baseline.gz, budget: null,
|
||
});
|
||
|
||
// 逐插件叠加(累计 = core + 已叠插件 + 本插件),差值 = 本插件增量。
|
||
const plugins = collectPlugins();
|
||
let prev = coreSize;
|
||
const cumulativeEntries = [CORE_ENTRY];
|
||
for (const p of plugins) {
|
||
cumulativeEntries.push(p.entry);
|
||
const cur = await bundleSize(cumulativeEntries, esbuild);
|
||
rows.push({
|
||
label: `+${p.name}`, cumRaw: cur.raw, cumGz: cur.gz,
|
||
deltaRaw: cur.raw - prev.raw, deltaGz: cur.gz - prev.gz, budget: p.budget,
|
||
});
|
||
prev = cur;
|
||
}
|
||
|
||
const md = renderSizesMd(rows, baseline);
|
||
const outPath = join(ROOT, 'SIZES.md');
|
||
writeFileSync(outPath, md);
|
||
console.log(`[size] 已写 ${outPath}(${plugins.length} 插件,增量法)`);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error('[size] 失败:' + (e && e.message ? e.message : e));
|
||
process.exit(1);
|
||
});
|