Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 1m51s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 1m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 7m43s
Project CI / Backend tests (pull_request) Failing after 1m51s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 1m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 7m43s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md # server-rs/crates/module-runtime/src/lib.rs
This commit is contained in:
@@ -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` + `features/ui-editor/types`。
|
||||
*
|
||||
* 生成文件一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写:这些目录
|
||||
* 同时登记在 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,所以这里直接逐字节
|
||||
* 比较原始输出即可,不需要(也不允许)再跑格式化。
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
@@ -15,67 +23,125 @@ 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';
|
||||
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(
|
||||
fs
|
||||
.readdirSync(generatedDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => [
|
||||
entry.name,
|
||||
fs.readFileSync(path.join(generatedDir, entry.name)),
|
||||
]),
|
||||
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)]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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} 个文件)。`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`生成绑定校验通过:${after.size} 个文件与 Rust 声明一致(${path.relative(repoRoot, generatedDir)})。`,
|
||||
);
|
||||
if (failed) process.exit(1);
|
||||
console.log(`生成绑定校验通过:合计 ${totalFiles} 个文件与 Rust 声明一致。`);
|
||||
|
||||
@@ -384,6 +384,28 @@ function assertDeletedConflict(result, recordLabel) {
|
||||
}
|
||||
|
||||
async function runSmoke(configPath, serverUrl) {
|
||||
const readFolder = (folderId, ownerUserId = 'receipt-owner') =>
|
||||
callProcedure(configPath, serverUrl, 'get_editor_asset_folder_and_return', {
|
||||
folder_id: folderId,
|
||||
owner_user_id: ownerUserId,
|
||||
});
|
||||
const missingBeforeCreate = await readFolder('missing-folder', 'empty-owner');
|
||||
assert(
|
||||
missingBeforeCreate.ok === false &&
|
||||
missingBeforeCreate.errorMessage === '素材文件夹不存在',
|
||||
'Missing folder read did not return the missing-folder error.',
|
||||
);
|
||||
assert(
|
||||
(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT folder_id FROM editor_asset_folder',
|
||||
'folders after missing read',
|
||||
)
|
||||
).length === 0,
|
||||
'Missing folder read created a default folder.',
|
||||
);
|
||||
const projectA = projectInput('project-a', 'First');
|
||||
assertOk(
|
||||
await callProcedure(
|
||||
@@ -471,6 +493,73 @@ async function runSmoke(configPath, serverUrl) {
|
||||
'asset folder drift replay',
|
||||
);
|
||||
|
||||
const folderRowsBeforeRead = await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_asset_folder',
|
||||
'folders before read',
|
||||
);
|
||||
const receiptsBeforeRead = await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_idempotent_create_receipt',
|
||||
'receipts before read',
|
||||
);
|
||||
assert(
|
||||
(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT asset_id FROM editor_asset',
|
||||
'assets before folder read',
|
||||
)
|
||||
).length === 0,
|
||||
'Folder read fixture must have no assets.',
|
||||
);
|
||||
const emptyFolder = await readFolder('folder-a');
|
||||
assertOk(emptyFolder, 'empty asset folder read');
|
||||
assert(
|
||||
emptyFolder.record[0] === 'folder-a' &&
|
||||
emptyFolder.record[1] === 'Folder A',
|
||||
'Folder read returned a different folder.',
|
||||
);
|
||||
const foreignFolder = await readFolder('folder-a', 'other-owner');
|
||||
assert(
|
||||
foreignFolder.ok === false &&
|
||||
foreignFolder.record === null &&
|
||||
foreignFolder.errorMessage === '无权访问该素材文件夹',
|
||||
'Foreign-owner folder read did not reject access.',
|
||||
);
|
||||
const missingFolder = await readFolder('missing-folder');
|
||||
assert(
|
||||
missingFolder.ok === false &&
|
||||
missingFolder.record === null &&
|
||||
missingFolder.errorMessage === '素材文件夹不存在',
|
||||
'Missing folder read did not fail closed.',
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_asset_folder',
|
||||
'folders after read',
|
||||
),
|
||||
) === JSON.stringify(folderRowsBeforeRead),
|
||||
'Folder reads changed folder rows or timestamps.',
|
||||
);
|
||||
assert(
|
||||
JSON.stringify(
|
||||
await sqlRows(
|
||||
configPath,
|
||||
serverUrl,
|
||||
'SELECT * FROM editor_idempotent_create_receipt',
|
||||
'receipts after read',
|
||||
),
|
||||
) === JSON.stringify(receiptsBeforeRead),
|
||||
'Folder reads changed durable receipts.',
|
||||
);
|
||||
|
||||
const sameRaceInput = projectInput('project-same-race', 'Same Race');
|
||||
const sameRace = await Promise.all([
|
||||
callProcedure(
|
||||
@@ -641,6 +730,12 @@ async function runSmoke(configPath, serverUrl) {
|
||||
},
|
||||
);
|
||||
assertOk(deleteFolderResult, 'asset folder delete');
|
||||
const deletedFolder = await readFolder('folder-a');
|
||||
assert(
|
||||
deletedFolder.ok === false &&
|
||||
deletedFolder.errorMessage === '素材文件夹不存在',
|
||||
'Deleted folder read did not return the missing-folder error.',
|
||||
);
|
||||
assertDeletedConflict(
|
||||
await callProcedure(
|
||||
configPath,
|
||||
@@ -757,7 +852,7 @@ export async function main() {
|
||||
|
||||
await runSmoke(configPath, serverUrl);
|
||||
console.log(
|
||||
'[editor-idempotency-smoke] Passed exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.',
|
||||
'[editor-idempotency-smoke] Passed folder reads (empty, missing, foreign owner, no writes), exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.',
|
||||
);
|
||||
} catch (error) {
|
||||
const standaloneOutput = sanitizeDiagnostic(
|
||||
|
||||
Reference in New Issue
Block a user