Files
Genarrative/scripts/check-generated-bindings.mjs
T
k88936 80afa2a139 修复:生成绑定校验不再因首个目标失败就中止
- 原来每个目标里直接 process.exit,第一个目标(shared-contracts)失败时 AGC 目标根本不会被检查,得反复重跑才知道还有哪些绑定陈旧
- 改为记录 failed 后 continue,跑完全部目标再统一退出 1,两个绑定集的错误一次报齐
2026-10-02 22:31:02 +08:00

163 lines
5.9 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
/**
* 校验 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';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
// Windows 上 `npx` 以 `npx.cmd` 落地,`spawnSync` 不带 shell 时解析不到(ENOENT)。
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx';
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 --locked -p shared-contracts --features ts-bindings --manifest-path server-rs/Cargo.toml 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 --locked --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)) {
console.warn(`目录不存在,跳过校验:${dir}`);
return new Map();
}
const entries = fs.readdirSync(absolute, { recursive: true });
return new Map(
entries.flatMap((entry) => {
const absoluteFile = path.join(absolute, entry);
if (!fs.lstatSync(absoluteFile).isFile()) return [];
const name = path.posix.join(dir, entry.split(path.sep).join('/'));
return [[name, fs.readFileSync(absoluteFile)]];
}),
);
}
function run(command, args) {
const result = spawnSync(command, args, { cwd: repoRoot, encoding: 'utf8' });
if (result.error) {
console.error(`无法启动 ${command}:${result.error.message}`);
}
return result;
}
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()]),
);
const result = run('cargo', target.cargoArgs);
if (result.status !== 0) {
failed = true;
console.error(
`生成绑定校验无法运行:${target.label} 的 export_bindings 未通过`,
);
if (result.stdout) console.error(result.stdout.trim());
if (result.stderr) console.error(result.stderr.trim());
continue;
}
if (target.formatDirs.length > 0) {
const formatted = run(npxCommand, [
'prettier',
'--write',
...target.formatDirs,
]);
if (formatted.status !== 0) {
failed = true;
console.error(
`生成绑定校验无法运行:${target.label} 的 prettier 格式化失败`,
);
if (formatted.stdout) console.error(formatted.stdout.trim());
if (formatted.stderr) console.error(formatted.stderr.trim());
continue;
}
}
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} 个文件)。`);
}
}
if (failed) process.exit(1);
console.log(`生成绑定校验通过:合计 ${totalFiles} 个文件与 Rust 声明一致。`);