构建:生成绑定校验覆盖 AGC shell 生成目录

- check-generated-bindings 同时覆盖 shared-contracts 与 AGC 的 chat/generated、services/generated、ui-editor/types
- ts-rs 重新生成后先按仓库格式跑 prettier 再逐字节比较,消除"格式不同"的假漂移
- 校验失败时按目标给出对应的重新生成命令
This commit is contained in:
2026-10-02 18:10:00 +08:00
parent 70aeb79be6
commit b7224aecef
+116 -51
View File
@@ -1,10 +1,18 @@
#!/usr/bin/env node
/**
* 校验 ts-rs 生成的共享契约绑定与 Rust 声明一致。
* 校验 ts-rs 生成的绑定与 Rust 声明一致。
*
* 做法是「重新生成一遍并与工作区现有内容逐字节比较」:只比 git diff 会漏掉「Rust 改了但
* 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接——
* 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。
*
* 覆盖两套 crate、三个目录:
* - `shared-contracts` → `packages/shared/src/contracts/generated`(原样落盘);
* - AGC shell → `chat/generated` + `services/generated`(随仓库统一走 prettier)。
*
* AGC 那两个目录是 prettier 格式化过的,而 ts-rs 输出的是双引号、不换行的原始形态;所以
* 重生成后要按仓库格式再跑一遍 prettier 才能比较,否则每次都会报"格式不同"的假漂移。
* 格式化范围只限这两个生成目录,不碰其它源码。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
@@ -15,67 +23,124 @@ const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const generatedDir = path.join(
repoRoot,
'packages/shared/src/contracts/generated',
);
function snapshot() {
if (!fs.existsSync(generatedDir)) return new Map();
const SHARED_CONTRACTS_DIR = 'packages/shared/src/contracts/generated';
const AGC_CHAT_DIR =
'apps/ai-game-creator-shell/src/view/project-development/chat/generated';
const AGC_SERVICES_DIR = 'apps/ai-game-creator-shell/src/services/generated';
// ui-editor 的绑定在 `.prettierignore` 里,ts-rs 原始输出就是提交形态,不需要再格式化。
const AGC_UI_EDITOR_DIR =
'apps/ai-game-creator-shell/src/features/ui-editor/types';
/** 一套绑定的生成入口、覆盖目录与"怎么重新生成"的提示。 */
const targets = [
{
label: 'shared-contracts',
dirs: [SHARED_CONTRACTS_DIR],
cargoArgs: [
'test',
'--locked',
'-p',
'shared-contracts',
'--features',
'ts-bindings',
'--manifest-path',
'server-rs/Cargo.toml',
'export_bindings',
],
formatDirs: [],
hint: 'cargo test -p shared-contracts --features ts-bindings export_bindings',
},
{
label: 'ai-game-creator-shell',
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR],
cargoArgs: [
'test',
'--locked',
'--manifest-path',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'export_bindings',
],
// ts-rs 落盘后按仓库格式跑一遍 prettier,比较的才是"提交时应有的内容"
// (ui-editor 的绑定被 `.prettierignore` 忽略,不在里面)。
formatDirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR],
hint: 'cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings && npx prettier --write apps/ai-game-creator-shell/src/view/project-development/chat/generated apps/ai-game-creator-shell/src/services/generated',
},
];
function snapshot(dir) {
const absolute = path.join(repoRoot, dir);
if (!fs.existsSync(absolute)) return new Map();
return new Map(
fs
.readdirSync(generatedDir, { withFileTypes: true })
.readdirSync(absolute, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => [
entry.name,
fs.readFileSync(path.join(generatedDir, entry.name)),
path.posix.join(dir, entry.name),
fs.readFileSync(path.join(absolute, entry.name)),
]),
);
}
const before = snapshot();
const result = spawnSync(
'cargo',
[
'test',
'--locked',
'-p',
'shared-contracts',
'--features',
'ts-bindings',
'--manifest-path',
'server-rs/Cargo.toml',
'export_bindings',
],
{ cwd: repoRoot, encoding: 'utf8' },
);
if (result.status !== 0) {
console.error('生成绑定校验无法运行:export_bindings 未通过');
if (result.stdout) console.error(result.stdout.trim());
if (result.stderr) console.error(result.stderr.trim());
process.exit(result.status ?? 1);
}
const after = snapshot();
const problems = [];
for (const [name, content] of after) {
const previous = before.get(name);
if (!previous) problems.push(`新增 ${name}`);
else if (!previous.equals(content)) problems.push(`内容变化 ${name}`);
}
for (const name of before.keys()) {
if (!after.has(name)) problems.push(`删除 ${name}`);
function run(command, args) {
return spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8' });
}
if (problems.length > 0) {
console.error('生成绑定与 Rust 声明不一致:');
for (const problem of problems) console.error(` - ${problem}`);
console.error(
'请运行 `cargo test -p shared-contracts --features ts-bindings export_bindings`,并把重新生成的结果与 Rust 改动一并提交。',
function diff(before, after) {
const problems = [];
for (const [name, content] of after) {
const previous = before.get(name);
if (!previous) problems.push(`新增 ${name}`);
else if (!previous.equals(content)) problems.push(`内容变化 ${name}`);
}
for (const name of before.keys()) {
if (!after.has(name)) problems.push(`删除 ${name}`);
}
return problems;
}
let totalFiles = 0;
let failed = false;
for (const target of targets) {
const before = new Map(
target.dirs.flatMap((dir) => [...snapshot(dir).entries()]),
);
process.exit(1);
const result = run('cargo', target.cargoArgs);
if (result.status !== 0) {
console.error(
`生成绑定校验无法运行:${target.label} 的 export_bindings 未通过`,
);
if (result.stdout) console.error(result.stdout.trim());
if (result.stderr) console.error(result.stderr.trim());
process.exit(result.status ?? 1);
}
if (target.formatDirs.length > 0) {
const formatted = run('npx', ['prettier', '--write', ...target.formatDirs]);
if (formatted.status !== 0) {
console.error(
`生成绑定校验无法运行:${target.label} 的 prettier 格式化失败`,
);
if (formatted.stdout) console.error(formatted.stdout.trim());
if (formatted.stderr) console.error(formatted.stderr.trim());
process.exit(formatted.status ?? 1);
}
}
const after = new Map(
target.dirs.flatMap((dir) => [...snapshot(dir).entries()]),
);
const problems = diff(before, after);
if (problems.length > 0) {
failed = true;
console.error(`生成绑定与 Rust 声明不一致(${target.label}):`);
for (const problem of problems) console.error(` - ${problem}`);
console.error(
`请运行 \`${target.hint}\`,并把重新生成的结果与 Rust 改动一并提交。`,
);
} else {
totalFiles += after.size;
console.log(`生成绑定校验通过:${target.label}(${after.size} 个文件)。`);
}
}
console.log(
`生成绑定校验通过:${after.size} 个文件与 Rust 声明一致(${path.relative(repoRoot, generatedDir)})。`,
);
if (failed) process.exit(1);
console.log(`生成绑定校验通过:合计 ${totalFiles} 个文件与 Rust 声明一致。`);