#!/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 ADMIN_RUST_FILE = 'server-rs/crates/shared-contracts/src/admin.rs'; const ADMIN_TS_FILE = 'apps/admin-web/src/api/adminApiTypes.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'], ['GameDistributionReview', 'GameDistributionReview'], ['GameDistributionRatingSummary', 'GameDistributionRatingSummary'], ['GameDistributionReviewsResponse', 'GameDistributionReviewsResponse'], ['GameDistributionMyReviewResponse', 'GameDistributionMyReviewResponse'], ['GameDistributionSaveReviewRequest', 'GameDistributionSaveReviewRequest'], ['GameDistributionSaveReviewResponse', 'GameDistributionSaveReviewResponse'], ['GameDistributionVersionSummary', 'GameDistributionVersionSummary'], ['GameDistributionGameSummary', 'GameDistributionGame'], ['GameDistributionListResponse', 'GameDistributionListResponse'], ['GameDistributionCreateGameRequest', 'GameDistributionCreateGameRequest'], [ 'GameDistributionCreateVersionRequest', 'GameDistributionCreateVersionRequest', ], ['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'], ['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'], ['AdminGameReviewGame', 'AdminGameReviewGame'], ['AdminGameReviewGamesResponse', 'AdminGameReviewGamesResponse'], ['AdminGameReviewsQuery', 'AdminGameReviewsQuery'], ['AdminGameReviewGameInfo', 'AdminGameReviewGameInfo'], ['AdminGameReview', 'AdminGameReview'], ['AdminGameReviewOperation', 'AdminGameReviewOperation'], ['AdminGameReviewsResponse', 'AdminGameReviewsResponse'], ['AdminGameReviewDetailResponse', 'AdminGameReviewDetailResponse'], ['AdminGameReviewModerationRequest', 'AdminGameReviewModerationRequest'], ['AdminGameReviewModerationResponse', 'AdminGameReviewModerationResponse'], ]; // 服务端逐字段手拼 JSON 的响应/请求(`public_game_payload` / `game_payload` 等),TS 里这些类型 // 没有 Rust 结构体可对:要收口得先把响应改成结构化类型,不能靠本脚本检查。 const TS_ONLY_TYPES = [ 'GameDistributionCategory', 'GameDistributionRecoveryAction', 'GameDistributionFrozenScreenshot', 'GameDistributionVersionFrozenMetadata', 'GameDistributionVersionDetail', 'GameDistributionCancelVersionRequest', 'GameDistributionCancelVersionResponse', ]; // 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。 // - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影追加 currentVersion、ratingSummary); // - `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', 'ratingSummary'], }, { 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(?(?:#\[[^\n]*\]\n)*)pub (?struct|enum) (?GameDistribution\w+|AdminGameReview\w*)(?[\s\S]*?)\n\}/g; let match; while ((match = pattern.exec(source))) { const { attrs, kind, name, body } = match.groups; const rename = /rename_all = "(?