新增游戏分发 Rust 与手写 TS DTO 的一致性门禁
- scripts/check-game-distribution-dto-parity.mjs:按显式映射表逐字段/逐变体比对 14 组 shared-contracts DTO 与 packages/shared/src/contracts/gameDistribution.ts,未分类的新类型或新字段直接失败 - 脚本登记 7 个服务端逐字段手拼 JSON 的 TS 类型,并记录 4 个已知响应侧附加字段(coverObjectKey/screenshots/publicationRevision)以便后续收口 - package.json 增加 check:game-distribution-dto-parity 并接进 npm run lint - 变异验证:TS 改名与 Rust 加字段各自都让门禁失败并指出缺哪个字段;里程碑第 6 条写明剩余缺口(两个响应仍是 serde_json 手拼)
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
#!/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 结构体」的说明清单;
|
||||
// - `TS_ONLY_FIELDS` 记录已知的、有原因的响应侧附加字段。
|
||||
// 任何一处没有分类的新类型、新字段都会让检查失败,避免静默漂移。
|
||||
|
||||
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';
|
||||
|
||||
// [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',
|
||||
];
|
||||
|
||||
// 已知的响应侧附加字段:Rust 结构体描述请求侧视图,TS 类型描述响应视图。
|
||||
// 只允许「TS 多出」这些字段;Rust 多出字段一律报错(TS 必须跟上)。
|
||||
const TS_ONLY_FIELDS = {
|
||||
GameDistributionGame: [
|
||||
'coverObjectKey',
|
||||
'screenshots',
|
||||
'publicationRevision',
|
||||
],
|
||||
GameDistributionPrivateVersion: ['publicationRevision'],
|
||||
};
|
||||
|
||||
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))) {
|
||||
result.set(match[1], {
|
||||
kind: 'struct',
|
||||
members: [...match[2].matchAll(/^ {2}(\w+)\??:/gm)].map(
|
||||
(item) => item[1],
|
||||
),
|
||||
});
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
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 allowlist = new Set(TS_ONLY_FIELDS[tsName] ?? []);
|
||||
const extraInTs = difference(
|
||||
tsDefinition.members,
|
||||
rustDefinition.members,
|
||||
).filter((value) => !allowlist.has(value));
|
||||
if (extraInTs.length > 0) {
|
||||
failures.push(
|
||||
`${tsName} 多出未登记字段/变体:${extraInTs.join(', ')}(要么补进 TS_ONLY_FIELDS 并写明原因,要么在 Rust 侧补上)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of rust.keys()) {
|
||||
if (!mappedRust.has(name)) {
|
||||
failures.push(`Rust 新增 DTO ${name} 没有登记进映射表(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 类型一致,${TS_ONLY_TYPES.length} 个手拼响应类型已登记`,
|
||||
);
|
||||
Reference in New Issue
Block a user