1b2bfa4e74
- 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 手拼)
196 lines
7.1 KiB
JavaScript
196 lines
7.1 KiB
JavaScript
#!/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} 个手拼响应类型已登记`,
|
||
);
|