3ab313a11a
- check-generated-bindings.mjs:run() 的 spawnSync 增加 shell: process.platform === 'win32' - Windows 上不带 shell 无法执行 .cmd/.bat(Node 修 CVE-2024-27980 后报 EINVAL),npxCommand 特意换成 npx.cmd 只把 ENOENT 换成了 EINVAL,prettier 这一步仍然跑不起来 - 与 scripts/database-backup-to-oss.mjs、lint-staged-rustfmt.mjs 等既有脚本的写法保持一致 - Linux 侧已跑 check:generated-bindings 验证;Windows 路径本机无法实测
172 lines
6.6 KiB
JavaScript
172 lines
6.6 KiB
JavaScript
#!/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)) {
|
||
// 目录不存在时旧实现返回空 Map:若前后快照都为空,diff 什么都不报,这个 target 会以
|
||
// "0 个文件"静默通过——`export_to` 路径被改名或生成文件被整体删除时,校验形同虚设。
|
||
throw new Error(`生成绑定目录不存在,无法校验:${dir}`);
|
||
}
|
||
// 四个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
|
||
// 不用 `recursive`,避免依赖 Node 18.17 / 20.1 才有的选项(旧版本会静默退化成只读顶层)。
|
||
const entries = fs.readdirSync(absolute);
|
||
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) {
|
||
// Windows 上 `.cmd` / `.bat` 不带 shell 无法执行(Node 修 CVE-2024-27980 后直接 EINVAL),
|
||
// `npx.cmd` 也不例外;仓库既有脚本统一用 `shell: process.platform === 'win32'`。
|
||
const result = spawnSync(command, args, {
|
||
cwd: repoRoot,
|
||
encoding: 'utf8',
|
||
shell: process.platform === 'win32',
|
||
});
|
||
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 声明一致。`);
|