#!/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) { 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 声明一致。`);