Files
Genarrative/scripts/check-game-distribution-dto-parity.mjs
T
kdletters 4c4e61255e
Project CI / AI game creator shell Rust crates (push) Successful in 1m29s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / Backend tests (push) Successful in 5m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m39s
Project CI / Native shell tests (push) Successful in 7m7s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m42s
Project CI / Frontend tests (push) Successful in 2m20s
Project CI / Repository checks (push) Successful in 2m25s
Project CI / AI game creator shell web tests (push) Successful in 1m38s
给游戏分发 DTO 门禁补上手拼响应构建器的逐键比对
- 新增 RESPONSE_BUILDERS:解析 api-server 里 game_payload / public_game_payload / private_version_payload / version_summary_payload 的 json! 顶层键与顶层 object.insert,逐键比对 TS 类型
- 发出的键必须都在 TS 类型里,TS 的必需字段必须都发出,公开投影还必须发出被标成可选的 currentVersion
- game_payload 的 author / deviceSupport 两个嵌套字面量同样逐键比对
- TS 类型解析同时记录必需字段,供这一层比对使用
- 同步游戏分发里程碑第 6 条的构建器取证与剩余缺口
2026-09-28 18:06:56 +08:00

461 lines
16 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
// 检查 game-distribution 的 Rust DTO 与手写 TS DTO 是否一致。
//
// 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。
// Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对:
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
// - 服务端手拼响应的构建器(`json!` / `object.insert`)单独比对顶层键:
// 类型字段一致只说明契约写得对,这一层才有证据说明构建器真的按契约发键。
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
import fs from 'node:fs';
const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs';
const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts';
const API_MODULE_FILE =
'server-rs/crates/api-server/src/modules/game_distribution.rs';
// [Rust 类型名, TS 类型名]
const PAIRS = [
[
'GameDistributionPublishMetadataSuggestionRequest',
'GameDistributionPublishMetadataSuggestionRequest',
],
[
'GameDistributionPublishMetadataSuggestion',
'GameDistributionPublishMetadataSuggestion',
],
['GameDistributionVersionStatus', 'GameDistributionVersionStatus'],
['GameDistributionVisibility', 'GameDistributionGameVisibility'],
['GameDistributionInputMode', 'GameDistributionInputMode'],
['GameDistributionOrientation', 'GameDistributionOrientation'],
['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'],
['GameDistributionAuthor', 'GameDistributionAuthor'],
['GameDistributionVersionSummary', 'GameDistributionVersionSummary'],
['GameDistributionGameSummary', 'GameDistributionGame'],
['GameDistributionListResponse', 'GameDistributionListResponse'],
['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'],
[
'GameDistributionCreateVersionRequest',
'GameDistributionCreateVersionRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
];
// 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型
// 没有 Rust 结构体可对:要收口得先把响应改成结构化类型,不能靠本脚本检查。
const TS_ONLY_TYPES = [
'GameDistributionCategory',
'GameDistributionRecoveryAction',
'GameDistributionFrozenScreenshot',
'GameDistributionVersionFrozenMetadata',
'GameDistributionVersionDetail',
'GameDistributionCancelVersionRequest',
'GameDistributionCancelVersionResponse',
];
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影 = 作者投影 + currentVersion);
// - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。
// 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。
const RESPONSE_BUILDERS = [
{
fn: 'game_payload',
ts: 'GameDistributionGame',
nested: [
{ key: 'author', ts: 'GameDistributionAuthor' },
{ key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' },
],
},
{
fn: 'public_game_payload',
ts: 'GameDistributionGame',
base: 'game_payload',
mustEmit: ['currentVersion'],
},
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
{ fn: 'version_summary_payload', ts: 'GameDistributionVersionSummary' },
];
function camelCase(value) {
return value.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
}
function snakeCase(value) {
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
}
// Rust 结构体字段本身就是 snake_case,枚举变体是 PascalCase;
// serde 的 rename_all 决定线上名字。
function renamedMember(value, kind, rename) {
if (kind === 'enum') {
if (rename === 'snake_case') return snakeCase(value);
if (rename === 'camelCase') return camelCase(snakeCase(value));
return value;
}
return rename === 'camelCase' ? camelCase(value) : value;
}
function rustDefinitions(source) {
const result = new Map();
const pattern =
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+)(?<body>[\s\S]*?)\n\}/g;
let match;
while ((match = pattern.exec(source))) {
const { attrs, kind, name, body } = match.groups;
const rename = /rename_all = "(?<style>\w+)"/.exec(attrs)?.groups?.style;
const members =
kind === 'struct'
? [...body.matchAll(/^\s*pub (\w+):/gm)].map((item) =>
renamedMember(item[1], kind, rename),
)
: [...body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)].map((item) =>
renamedMember(item[1], kind, rename),
);
result.set(name, { kind, members });
}
return result;
}
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
/export type (GameDistribution\w+) = \{([\s\S]*?)\n\};/g;
let match;
while ((match = objectPattern.exec(source))) {
const members = [];
const required = [];
for (const line of match[2].split('\n')) {
const member = /^ {2}(\w+)(\??):/.exec(line);
if (!member) continue;
members.push(member[1]);
if (member[2] !== '?') required.push(member[1]);
}
result.set(match[1], { kind: 'struct', members, required });
}
const unionPattern = /export type (GameDistribution\w+) =\s*([^;]+);/g;
while ((match = unionPattern.exec(source))) {
if (result.has(match[1])) continue;
result.set(match[1], {
kind: 'enum',
members: [...match[2].matchAll(/'([^']+)'/g)].map((item) => item[1]),
});
}
return result;
}
function difference(left, right) {
const rightSet = new Set(right);
return left.filter((value) => !rightSet.has(value));
}
// 跳过 Rust 字符串字面量,返回结束引号的下标。
function skipString(source, start) {
let index = start + 1;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === '"') return index;
index += 1;
}
return source.length - 1;
}
// 取顶层函数的函数体文本;找不到函数或括号不闭合时返回 null。
function functionBody(source, name) {
const signature = new RegExp(`\\nfn ${name}\\(`).exec(source);
if (!signature) return null;
const open = source.indexOf('{', signature.index + signature[0].length);
if (open < 0) return null;
let depth = 0;
for (let index = open; index < source.length; index += 1) {
const char = source[index];
if (char === '"') {
index = skipString(source, index);
continue;
}
if (char === "'") {
const charLiteral = /^'(?:\\.|[^'\\])'/.exec(source.slice(index));
if (charLiteral) index += charLiteral[0].length - 1;
continue;
}
if (char === '{') depth += 1;
else if (char === '}') {
depth -= 1;
if (depth === 0) return source.slice(open + 1, index);
}
}
return null;
}
// 取第一个 `json!({ ... })` 字面量文本;没有则返回 null。
function firstJsonLiteral(body) {
const marker = 'json!({';
const start = body.indexOf(marker);
if (start < 0) return null;
const open = start + marker.length - 1;
let depth = 0;
for (let index = open; index < body.length; index += 1) {
const char = body[index];
if (char === '"') {
index = skipString(body, index);
continue;
}
if (char === '{' || char === '[' || char === '(') depth += 1;
else if (char === '}' || char === ']' || char === ')') {
depth -= 1;
if (depth === 0) return body.slice(open, index + 1);
}
}
return null;
}
// 取括号对的结束下标;不闭合返回 -1。
function matchingBracket(text, open) {
let depth = 0;
for (let index = open; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
index = skipString(text, index);
continue;
}
if (char === '{' || char === '[' || char === '(') depth += 1;
else if (char === '}' || char === ']' || char === ')') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
// 取对象字面量的直接键:只有上一个有效字符是 `{` 或 `,` 且下一字符是 `:`
// 的字符串才算键,因此嵌套对象的键与作为值的字符串不会混进来。
function objectKeys(text) {
const keys = [];
let inner = 0;
let previous = '';
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
const close = skipString(text, index);
if (
inner === 1 &&
(previous === '{' || previous === ',') &&
text[close + 1] === ':'
) {
keys.push(text.slice(index + 1, close));
previous = ':';
index = close;
continue;
}
previous = '"';
index = close;
continue;
}
if (char === '{' || char === '[' || char === '(') inner += 1;
else if (char === '}' || char === ']' || char === ')') inner -= 1;
if (!/\s/.test(char)) previous = char;
}
return keys;
}
// 取对象字面量里某个直接键对应的嵌套对象字面量文本;该键不是对象字面量时返回 null。
function nestedObjectText(text, wanted) {
let inner = 0;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
const close = skipString(text, index);
if (inner === 1 && text.slice(index + 1, close) === wanted) {
let cursor = close + 1;
while (cursor < text.length && /\s/.test(text[cursor])) cursor += 1;
if (text[cursor] === ':') {
let open = cursor + 1;
while (open < text.length && /\s/.test(text[open])) open += 1;
if (text[open] !== '{') return null;
const end = matchingBracket(text, open);
return end < 0 ? null : text.slice(open, end + 1);
}
}
index = close;
continue;
}
if (char === '{' || char === '[' || char === '(') inner += 1;
else if (char === '}' || char === ']' || char === ')') inner -= 1;
}
return null;
}
// 取函数体里顶层追加的键:`object.insert("key".to_string(), ...)`。
function insertedKeys(body) {
return [...body.matchAll(/object\.insert\(\s*"([^"]+)"/g)].map(
(item) => item[1],
);
}
const rust = rustDefinitions(fs.readFileSync(RUST_FILE, 'utf8'));
const ts = tsDefinitions(fs.readFileSync(TS_FILE, 'utf8'));
const failures = [];
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
const mappedTs = new Set(PAIRS.map(([, tsName]) => tsName));
for (const [rustName, tsName] of PAIRS) {
const rustDefinition = rust.get(rustName);
const tsDefinition = ts.get(tsName);
if (!rustDefinition) {
failures.push(`映射表里的 Rust 类型不存在:${rustName}`);
continue;
}
if (!tsDefinition) {
failures.push(`映射表里的 TS 类型不存在:${tsName}`);
continue;
}
if (rustDefinition.kind !== tsDefinition.kind) {
failures.push(
`${rustName} 与 ${tsName} 形状不一致(Rust=${rustDefinition.kind} TS=${tsDefinition.kind})`,
);
}
const missingInTs = difference(rustDefinition.members, tsDefinition.members);
if (missingInTs.length > 0) {
failures.push(
`${tsName} 缺少字段/变体:${missingInTs.join(', ')}(${rustName} 已有)`,
);
}
const extraInTs = difference(tsDefinition.members, rustDefinition.members);
if (extraInTs.length > 0) {
failures.push(
`${tsName} 多出未登记字段/变体:${extraInTs.join(', ')}(要么在 Rust 侧补上,要么先确认真实响应不再发出)`,
);
}
}
for (const name of rust.keys()) {
if (!mappedRust.has(name)) {
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(TS 侧必须同步)`);
}
}
const apiSource = fs.readFileSync(API_MODULE_FILE, 'utf8');
const emittedByBuilder = new Map();
for (const builder of RESPONSE_BUILDERS) {
const tsDefinition = ts.get(builder.ts);
if (!tsDefinition) {
failures.push(`响应构建器 ${builder.fn} 的 TS 类型不存在:${builder.ts}`);
continue;
}
const body = functionBody(apiSource, builder.fn);
if (body === null) {
failures.push(`响应构建器 ${builder.fn} 在 ${API_MODULE_FILE} 里找不到`);
continue;
}
const literal = firstJsonLiteral(body);
const literalKeys = literal ? objectKeys(literal) : [];
const ownKeys = [...new Set([...literalKeys, ...insertedKeys(body)])];
let emitted = ownKeys;
if (builder.base) {
if (literalKeys.length > 0) {
failures.push(
`${builder.fn} 声明了 base=${builder.base},却自己也有 json! 字面量;两者只能取其一`,
);
continue;
}
if (ownKeys.length === 0) {
failures.push(
`${builder.fn} 声明了 base=${builder.base},但没有解析到任何顶层 object.insert("…")`,
);
continue;
}
const baseKeys = emittedByBuilder.get(builder.base);
if (!baseKeys) {
failures.push(
`${builder.fn} 的 base=${builder.base} 必须登记在它前面,才能复用已解析的键`,
);
continue;
}
emitted = [...new Set([...baseKeys, ...ownKeys])];
} else if (literalKeys.length === 0) {
failures.push(`${builder.fn} 没有解析到任何 json!({ … }) 顶层键`);
continue;
}
emittedByBuilder.set(builder.fn, emitted);
const unknown = difference(emitted, tsDefinition.members);
if (unknown.length > 0) {
failures.push(
`${builder.fn} 发出 ${builder.ts} 没有的键:${unknown.join(', ')}(TS 类型必须同步)`,
);
}
const missing = difference(tsDefinition.required ?? [], emitted);
if (missing.length > 0) {
failures.push(
`${builder.fn} 没有发出 ${builder.ts} 的必需字段:${missing.join(', ')}`,
);
}
const mustEmit = difference(builder.mustEmit ?? [], emitted);
if (mustEmit.length > 0) {
failures.push(`${builder.fn} 必须发出的键缺失:${mustEmit.join(', ')}`);
}
for (const nested of builder.nested ?? []) {
const nestedDefinition = ts.get(nested.ts);
if (!nestedDefinition) {
failures.push(
`${builder.fn} 的嵌套对象 ${nested.key} 对应的 TS 类型不存在:${nested.ts}`,
);
continue;
}
const nestedText = literal ? nestedObjectText(literal, nested.key) : null;
if (!nestedText) {
failures.push(
`${builder.fn} 的 ${nested.key} 不是对象字面量,无法与 ${nested.ts} 比对`,
);
continue;
}
const nestedKeys = objectKeys(nestedText);
const nestedUnknown = difference(nestedKeys, nestedDefinition.members);
if (nestedUnknown.length > 0) {
failures.push(
`${builder.fn} 的 ${nested.key} 发出 ${nested.ts} 没有的键:${nestedUnknown.join(', ')}`,
);
}
const nestedMissing = difference(
nestedDefinition.required ?? [],
nestedKeys,
);
if (nestedMissing.length > 0) {
failures.push(
`${builder.fn} 的 ${nested.key} 没有发出 ${nested.ts} 的必需字段:${nestedMissing.join(', ')}`,
);
}
}
}
for (const builder of RESPONSE_BUILDERS) {
if (!emittedByBuilder.has(builder.fn)) {
failures.push(
`响应构建器 ${builder.fn} 的键没有解析成功,无法证明与 TS 一致`,
);
}
}
for (const name of ts.keys()) {
if (!mappedTs.has(name) && !TS_ONLY_TYPES.includes(name)) {
failures.push(
`TS 新增类型 ${name} 未分类(映射表或 TS_ONLY_TYPES 二者必居其一)`,
);
}
}
if (failures.length > 0) {
console.error('[check:game-distribution-dto-parity] 不一致:');
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}
console.log(
`[check:game-distribution-dto-parity] OK:${PAIRS.length} 组 Rust/TS 类型一致,` +
`${RESPONSE_BUILDERS.length} 个手拼响应构建器键一致,` +
`${TS_ONLY_TYPES.length} 个手拼响应类型已登记`,
);