#!/usr/bin/env node /** * 校验 ts-rs 生成的绑定与 Rust 声明一致。 * * 做法是「重新生成一遍并与工作区现有内容逐字节比较」:只比 git diff 会漏掉「Rust 改了但 * 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接—— * 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。 * * 覆盖两套 crate、四个目录: * - `shared-contracts` → `packages/shared/src/contracts/generated`; * - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types`。 * * 生成文件一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写:这些目录 * 同时登记在 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,所以这里直接逐字节 * 比较原始输出即可,不需要(也不允许)再跑格式化。 */ 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)), '..', ); 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'; 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', ], 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', ], hint: 'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings', }, ]; 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); // 仓库既有脚本统一用 `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; } 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 声明一致。`);