a898e18a9f
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
真实库 `GET /games/{A111}` 的血缘摘要里 `parentTitle` 有值而 `parentAuthorName` 为 `null`,详情页溯源条
「父作品 · 作者名」开天窗。根因核实:`game_distribution_lineage_snapshot` 直接用了发布时冻结的
`parent.author_name`,而老数据 / 程序化创建的作品上该列常为 `None`——公开投影与族谱节点早已改成**读时联
`user_account`**,只有血缘摘要没跟上(同一处语义没同步)。
- 血缘摘要改用 `game_distribution_lineage_author_name`(与族谱节点**同一实现**):账号显示名优先 →
账号不可得/昵称为空白时退回冻结作者名 → 两者都为空白则 `None`(不占位、不回退空串、不编造),
与公开详情同口径;父作品不可用时仍走原有的 `fork_lineage_visible_identity` 三态裁剪,不泄露已删作品身份。
- 新增结构回归测试 `lineage_summary_resolves_parent_author_name_at_read_time`:血缘快照必须调用账号兜底
函数,并**不得**再直接出现 `parent.author_name`(宿主起不了真库,行为证据留给真实栈复验)。
- parity 新增登记 `lineage_payload`(构建器 17→**18**):把「builder 发出的键 == DTO 字段」钉住,并要求
`parentAuthorName` **恒发该键**(值为 `null` 也要发——缺键与 `null` 在前端是两种渲染分支,不能用
optional 表达)。
- 文档 §3.4:写明读时兜底的口径与优先级;并明确**不新增 `rootAuthorName`**——已只读核实前端
`GameDetailPage` 只消费 `parentAuthorName`(`CoCreationLineageStrip` 用的是族谱节点的 `authorName`),
根侧只发 `rootTitle`,不加字段以免契约膨胀。
- **无字段形状变更**(Rust DTO 与 TS 均未增删字段,故无 TS/绑定改动)。
门禁:wasm build 0;`cargo check --all-targets` 0;`cargo test -p spacetime-module` **306 passed / 1 ignored**;
`cargo test -p module-game-distribution` 134 passed;`cargo test -p api-server game_distribution` 107 passed;
DTO parity 0(66 组 / **18** 构建器 / 15 手拼类型);`check:spacetime-schema` 0(98 表);
`check:encoding` 0(5546 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;
`git diff --check` 0。
未在真实库手动改任何数据(按要求);修完请另一个 session 用真实数据复验 `parentAuthorName`。
805 lines
32 KiB
JavaScript
805 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
||
// 检查游戏分发与创作者公开查询的 Rust DTO 与手写 TS DTO 是否一致。
|
||
//
|
||
// 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。
|
||
// Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对:
|
||
// - 映射表同时是「哪些 Rust DTO 必须在 TS 里有对应类型」的清单;
|
||
// - `TS_ONLY_TYPES` 是「服务端手拼 JSON、没有 Rust 结构体」的说明清单;
|
||
// - 两侧字段必须逐一对齐,任一方向多出字段都会失败(没有白名单)。
|
||
// - 两侧字段的可空性必须一致:TS 允许 `null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时
|
||
// TS 必须可空或可省略,避免「Rust 契约写 String、线上实际下发 null」这类漂移。
|
||
// - 服务端手拼响应的构建器(`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 CREATOR_RUST_FILE = 'server-rs/crates/shared-contracts/src/creator.rs';
|
||
const CREATOR_TS_FILE = 'packages/shared/src/contracts/creator.ts';
|
||
const API_MODULE_FILE =
|
||
'server-rs/crates/api-server/src/modules/game_distribution.rs';
|
||
|
||
// [Rust 类型名, TS 类型名]
|
||
const PAIRS = [
|
||
['CreatorUser', 'CreatorUser'],
|
||
['CreatorRelationship', 'CreatorRelationship'],
|
||
['CreatorProfile', 'CreatorProfile'],
|
||
['CreatorConnection', 'CreatorConnection'],
|
||
['CreatorConnections', 'CreatorConnections'],
|
||
['CreatorRelationshipResponse', 'CreatorRelationshipResponse'],
|
||
[
|
||
'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',
|
||
],
|
||
[
|
||
'GameDistributionUpdateGameMetadataRequest',
|
||
'GameDistributionUpdateGameMetadataRequest',
|
||
],
|
||
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
|
||
// 共创(Fork)授权与血缘:档位枚举与公开血缘摘要必须与 TS 逐字对齐。
|
||
['GameDistributionForkAuthorization', 'GameDistributionForkAuthorization'],
|
||
['GameDistributionLineage', 'GameDistributionLineage'],
|
||
['GameDistributionLineageNode', 'GameDistributionLineageNode'],
|
||
['GameDistributionLineageResponse', 'GameDistributionLineageResponse'],
|
||
['GameDistributionDerivedResponse', 'GameDistributionDerivedResponse'],
|
||
// 贡献归集与归因(作者视角只读,2026-10-06):指标集合、按代际分解、直接子代明细与响应体
|
||
// 逐字段对齐。`truncatedReason` 在 Rust 是 `Option<String>`(未截断为 `null`),TS 侧必须可空。
|
||
['GameDistributionContributionTotals', 'GameDistributionContributionTotals'],
|
||
[
|
||
'GameDistributionContributionGeneration',
|
||
'GameDistributionContributionGeneration',
|
||
],
|
||
['GameDistributionContributionChild', 'GameDistributionContributionChild'],
|
||
[
|
||
'GameDistributionContributionResponse',
|
||
'GameDistributionContributionResponse',
|
||
],
|
||
// M2a 取件通道:元数据响应与内容形态同样逐字段对齐(不含对象键是契约的一部分)。
|
||
['GameDistributionForkSourceKind', 'GameDistributionForkSourceKind'],
|
||
['GameDistributionForkSource', 'GameDistributionForkSource'],
|
||
['GameDistributionForkSourceResponse', 'GameDistributionForkSourceResponse'],
|
||
['GameDistributionForkDeclaration', 'GameDistributionForkDeclaration'],
|
||
[
|
||
'GameDistributionSetForkAuthorizationRequest',
|
||
'GameDistributionSetForkAuthorizationRequest',
|
||
],
|
||
// 收藏(收录):PUT / DELETE 共用同一个权威投影值形状(`replayed` 只有 PUT 会发);
|
||
// 列表响应用独立类型登记,避免与公开目录的分页口径混成一个契约。
|
||
['GameDistributionCollectionState', 'GameDistributionCollectionState'],
|
||
[
|
||
'GameDistributionMyCollectionsResponse',
|
||
'GameDistributionMyCollectionsResponse',
|
||
],
|
||
// 共创主题:三态枚举、详情增量条目、公开列表 / 详情与后台写请求逐字段对齐。
|
||
// 公开列表 / 详情的手拼响应构建器(`public_themes_payload` / `public_theme_detail_payload`、
|
||
// 以及作品详情的 `themes` 增量)要等 api-server 路由落地后才登记进 `RESPONSE_BUILDERS`——
|
||
// 那里要求函数真实存在,登记不存在的构建器会让本检查直接失败。
|
||
['GameDistributionThemeStatus', 'GameDistributionThemeStatus'],
|
||
['GameDistributionThemeReference', 'GameDistributionThemeReference'],
|
||
['GameDistributionThemeSummary', 'GameDistributionThemeSummary'],
|
||
['GameDistributionThemeListResponse', 'GameDistributionThemeListResponse'],
|
||
['GameDistributionThemeDetail', 'GameDistributionThemeDetail'],
|
||
['GameDistributionCreateThemeRequest', 'GameDistributionCreateThemeRequest'],
|
||
['GameDistributionUpdateThemeRequest', 'GameDistributionUpdateThemeRequest'],
|
||
[
|
||
'GameDistributionUpsertThemeMemberRequest',
|
||
'GameDistributionUpsertThemeMemberRequest',
|
||
],
|
||
['GameDistributionPurchaseRequest', 'GameDistributionPurchaseRequest'],
|
||
['GameDistributionPurchase', 'GameDistributionPurchase'],
|
||
['GameDistributionPurchaseResponse', 'GameDistributionPurchaseResponse'],
|
||
[
|
||
'GameDistributionPlaySessionResponse',
|
||
'GameDistributionPlaySessionResponse',
|
||
],
|
||
['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(`admin_theme_payload` 一族),
|
||
// 因此同样只能走本清单 + `RESPONSE_BUILDERS` 登记,而不是 Rust↔TS 映射表。
|
||
'GameDistributionAdminTheme',
|
||
'GameDistributionAdminThemeListResponse',
|
||
'GameDistributionAdminThemeMutationResponse',
|
||
'GameDistributionAdminThemeMemberResponse',
|
||
'GameDistributionAdminThemeMemberRemovalResponse',
|
||
// 后台主题成员**读**接口:列表响应与单条行也都是手拼 JSON(服务端没有对应的 Rust 结构体),
|
||
// 与上面四个同类登记;`visibility` 联合类型是服务端按游戏行现算的字符串,同样只有 TS 侧定义。
|
||
'GameDistributionAdminThemeMemberRow',
|
||
'GameDistributionAdminThemeMemberListResponse',
|
||
'GameDistributionAdminThemeMemberVisibility',
|
||
];
|
||
|
||
// 服务端逐字段手拼 JSON 的响应构建器:把「这个函数真的会发出的顶层键」与 TS 类型逐键比对。
|
||
// - `base`:该构建器在另一个已登记构建器的结果上追加键(公开投影追加 currentVersion、ratingSummary);
|
||
// - `mustEmit`:TS 类型为了同时描述两种形状把该键标成可选,但这条路径必须发出。
|
||
// 只解析顶层键;需要一并比对的嵌套对象用 `nested` 显式登记,没登记的嵌套对象不在证据范围内。
|
||
const RESPONSE_BUILDERS = [
|
||
{
|
||
fn: 'game_payload',
|
||
ts: 'GameDistributionGame',
|
||
// `forkAuthorization` 在 TS 里是可选的(旧响应可省略),但**公开与作者响应都必须发出**它:
|
||
// 详情页的授权徽章与「改造这个作品」入口都读它,缺了会静默退化成「禁止共创」。
|
||
// mustEmit 是唯一能表达「可选字段在这条路径上必出」的机制,所以钉在这里;
|
||
// public_game_payload / owner_game_entry_payload 都以它为 base,因此一并受保护。
|
||
mustEmit: ['forkAuthorization'],
|
||
nested: [
|
||
{ key: 'author', ts: 'GameDistributionAuthor' },
|
||
{ key: 'deviceSupport', ts: 'GameDistributionDeviceSupport' },
|
||
],
|
||
},
|
||
{
|
||
fn: 'public_game_payload',
|
||
ts: 'GameDistributionGame',
|
||
base: 'game_payload',
|
||
mustEmit: ['currentVersion', 'ratingSummary', 'forkCount', 'lineage'],
|
||
},
|
||
{
|
||
// 公开详情在 `public_game_payload` 之上按**可选登录态**追加 `collected`;匿名请求不加键,
|
||
// 因此它不能进 `mustEmit`(那条路径本来就不发),但必须是一个真实构建器:
|
||
// 这层证据说明「登录时确实会发出 `collected`」,而不是只写在 DTO 注释里。
|
||
fn: 'public_game_detail_payload',
|
||
ts: 'GameDistributionGame',
|
||
base: 'public_game_payload',
|
||
// `themes` 与 `collected` 相反:**匿名与登录都发**(主题入口是公开页的一部分,不是 per-user
|
||
// 数据),没有所属主题时发空数组而不是缺键,因此它必须进 mustEmit。
|
||
mustEmit: ['themes'],
|
||
},
|
||
{ fn: 'private_version_payload', ts: 'GameDistributionPrivateVersion' },
|
||
{
|
||
// 公开版本摘要:`changeSummary` 在 TS 里可省略(旧响应没有这个键),但这条路径**必须发出**
|
||
// ——衍生作品上它是详情页 / 族谱溯源展示「每一代差异」的唯一来源,缺了会静默退化成「无差异」;
|
||
// 母版发 `null`(不是省略),「没有来源作品」与「服务端忘了发」必须可分辨。
|
||
fn: 'version_summary_payload',
|
||
ts: 'GameDistributionVersionSummary',
|
||
mustEmit: ['changeSummary'],
|
||
},
|
||
{
|
||
// 「我的收藏」复用公开目录的条目形状,但有自己的分页契约(默认 20 / 上限 50 / 真实游标):
|
||
// `nextCursor` 是这条路径**必须**发出的键(最后一页发 `null`,不是省略)。
|
||
fn: 'my_collections_payload',
|
||
ts: 'GameDistributionMyCollectionsResponse',
|
||
mustEmit: ['games', 'nextCursor'],
|
||
},
|
||
{
|
||
// 公开共创主题列表:`themes` / `nextCursor` 两个键都必须发出(末页 `nextCursor: null`)。
|
||
// 单条的形状由 `public_theme_summary_payload` 拼,这里只证明顶层键与 TS 契约一致。
|
||
fn: 'public_themes_payload',
|
||
ts: 'GameDistributionThemeListResponse',
|
||
mustEmit: ['themes', 'nextCursor'],
|
||
},
|
||
{
|
||
// 公开共创主题详情:顶层扁平(与 `GET /games/{gameId}` 同形),**七个**键都由这一条路径发出。
|
||
// `memberCount`(真实可见成员数,不截断)与 `rootsTruncated`(这份 `roots` 有没有被响应体积
|
||
// 上限截断)是两个独立口径,缺了后者客户端就只能靠「两个数不相等」去猜自己是否少看了。
|
||
// `roots` 逐条复用公开目录同一份 `public_game_payload`(不是第二套作品摘要)。
|
||
fn: 'public_theme_detail_payload',
|
||
ts: 'GameDistributionThemeDetail',
|
||
mustEmit: [
|
||
'themeId',
|
||
'name',
|
||
'summary',
|
||
'badge',
|
||
'memberCount',
|
||
'roots',
|
||
'rootsTruncated',
|
||
],
|
||
},
|
||
{
|
||
// 主题条目(列表单条与详情头部共用):`public_themes_payload` 里逐条调用它,`roots` 之外
|
||
// 的嵌套对象在顶层键解析里看不到,所以单独登记,让「条目到底发哪几个键」有独立证据。
|
||
fn: 'public_theme_summary_payload',
|
||
ts: 'GameDistributionThemeSummary',
|
||
mustEmit: ['themeId', 'name', 'summary', 'badge', 'memberCount'],
|
||
},
|
||
{
|
||
// 作品详情 `themes` 增量的单条:`{ themeId, name, badge }`(不含简介与成员数——那是主题页的事)。
|
||
// 它是**数组元素**,顶层键解析抓不到它,因此单独登记,让这条增量条目的形状也有证据。
|
||
fn: 'theme_reference_payload',
|
||
ts: 'GameDistributionThemeReference',
|
||
mustEmit: ['themeId', 'name', 'badge'],
|
||
},
|
||
{
|
||
// 后台主题条目:`admin_themes_payload` 逐条调用它,且它比公开的 `GameDistributionThemeSummary`
|
||
// 多出 `sortOrder` / `status` / 时间戳、`memberCount` 口径也不同(成员**行**总数),
|
||
// 因此单独建类型、单独登记——后台 UI 落地时形状漂移会被这里抓住。
|
||
fn: 'admin_theme_payload',
|
||
ts: 'GameDistributionAdminTheme',
|
||
mustEmit: [
|
||
'themeId',
|
||
'name',
|
||
'summary',
|
||
'badge',
|
||
'sortOrder',
|
||
'status',
|
||
'memberCount',
|
||
'createdAt',
|
||
'updatedAt',
|
||
],
|
||
},
|
||
{
|
||
// 后台主题列表:只有一个顶层键 `themes`(**没有** `nextCursor`:这条路径不分页)。
|
||
fn: 'admin_themes_payload',
|
||
ts: 'GameDistributionAdminThemeListResponse',
|
||
mustEmit: ['themes'],
|
||
},
|
||
{
|
||
// 后台创建 / 更新的响应:`{ theme, replayed }`。`theme` 的值是函数调用而不是对象字面量,
|
||
// 所以不能用 `nested` 比对;条目形状由上面 `admin_theme_payload` 那条独立覆盖。
|
||
fn: 'admin_theme_mutation_payload',
|
||
ts: 'GameDistributionAdminThemeMutationResponse',
|
||
mustEmit: ['theme', 'replayed'],
|
||
},
|
||
{
|
||
// 后台成员写入的响应:`{ themeId, rootGameId, sortOrder, createdAt }`。
|
||
fn: 'admin_theme_member_payload',
|
||
ts: 'GameDistributionAdminThemeMemberResponse',
|
||
mustEmit: ['themeId', 'rootGameId', 'sortOrder', 'createdAt'],
|
||
},
|
||
{
|
||
// 后台移除成员的响应:`{ themeId, rootGameId }`(刻意不含「之前存不存在」,重复调用逐字节相同)。
|
||
fn: 'admin_theme_member_removal_payload',
|
||
ts: 'GameDistributionAdminThemeMemberRemovalResponse',
|
||
mustEmit: ['themeId', 'rootGameId'],
|
||
},
|
||
{
|
||
// 后台成员**读**接口的列表响应:`totalMembers` 是**成员行总数**(含当前不可见的行、不受分页
|
||
// 影响),`nextCursor` 必须发出(末页为 `null`,不是省略)——缺了它客户端会把末页当成
|
||
// 「还有下一页」并死循环翻页。
|
||
fn: 'admin_theme_members_payload',
|
||
ts: 'GameDistributionAdminThemeMemberListResponse',
|
||
mustEmit: ['themeId', 'totalMembers', 'members', 'nextCursor'],
|
||
},
|
||
{
|
||
// 后台成员行的单条形状:`visibility` 与 `visible` 是两个独立字段(前者回答「为什么」、后者
|
||
// 回答「此刻公开侧会不会出现」),`title` 在游戏行不存在时为 `null` 但**键必须发出**。
|
||
// 它是数组元素,顶层键解析抓不到它,因此单独登记。
|
||
fn: 'admin_theme_member_row_payload',
|
||
ts: 'GameDistributionAdminThemeMemberRow',
|
||
mustEmit: [
|
||
'rootGameId',
|
||
'title',
|
||
'sortOrder',
|
||
'createdAt',
|
||
'visible',
|
||
'visibility',
|
||
],
|
||
},
|
||
{
|
||
// 公开详情里的血缘摘要(`GET /games/{gameId}` 的 `lineage`)。TS 里 `parentAuthorName` 是可选的
|
||
// (表达「历史响应可能缺键」),但这条路径**必须恒发该键**(值为 `null` 也要发):它是溯源条
|
||
// 「父作品 · 作者名」的唯一来源,「缺键」与「null」在前端是两种渲染分支,不能靠 optional 表达。
|
||
// 这条登记同时把「builder 发出的键 == DTO 字段」钉住,防止再出现「加了字段忘了发」。
|
||
fn: 'lineage_payload',
|
||
ts: 'GameDistributionLineage',
|
||
mustEmit: ['parentAuthorName'],
|
||
},
|
||
];
|
||
|
||
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+|AdminGameReview\w*|Creator\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 = [];
|
||
const optionalFields = [];
|
||
if (kind === 'struct') {
|
||
for (const item of body.matchAll(/^\s*pub (\w+):\s*(.+?)\s*,?\s*$/gm)) {
|
||
const member = renamedMember(item[1], kind, rename);
|
||
members.push(member);
|
||
// `Option<T>` 是「允许 null」的唯一 Rust 依据。
|
||
if (/\bOption</.test(item[2])) optionalFields.push(member);
|
||
}
|
||
} else {
|
||
for (const item of body.matchAll(/^\s{4}([A-Z]\w*)(?:\(|,|\s*\{)/gm)) {
|
||
members.push(renamedMember(item[1], kind, rename));
|
||
}
|
||
}
|
||
result.set(name, { kind, members, optionalFields });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// 括号是否闭合:跨行的 TS 类型只解析到第一行,无法判断可空性,这类字段跳过比对。
|
||
function balancedType(typeText) {
|
||
let depth = 0;
|
||
for (const char of typeText) {
|
||
if (char === '{' || char === '[' || char === '(' || char === '<')
|
||
depth += 1;
|
||
else if (char === '}' || char === ']' || char === ')' || char === '>')
|
||
depth -= 1;
|
||
}
|
||
return depth === 0;
|
||
}
|
||
|
||
// 只认字段自身类型上的 `| null`;内联对象 / 泛型里的 `| null` 属于嵌套成员,不算。
|
||
function topLevelNullable(typeText) {
|
||
let depth = 0;
|
||
for (let index = 0; index < typeText.length; index += 1) {
|
||
const char = typeText[index];
|
||
if (char === '{' || char === '[' || char === '(' || char === '<')
|
||
depth += 1;
|
||
else if (char === '}' || char === ']' || char === ')' || char === '>')
|
||
depth -= 1;
|
||
else if (
|
||
char === '|' &&
|
||
depth === 0 &&
|
||
/^\s*null\b/.test(typeText.slice(index + 1))
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function tsDefinitions(source) {
|
||
const result = new Map();
|
||
const objectPattern =
|
||
/export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*|Creator\w+)(?: =)? \{([\s\S]*?)\n\};?/g;
|
||
let match;
|
||
while ((match = objectPattern.exec(source))) {
|
||
const members = [];
|
||
const required = [];
|
||
const nullable = [];
|
||
const complete = [];
|
||
for (const line of match[2].split('\n')) {
|
||
const member = /^ {2}(\w+)(\??):\s*(.*)$/.exec(line);
|
||
if (!member) continue;
|
||
members.push(member[1]);
|
||
if (member[2] !== '?') required.push(member[1]);
|
||
if (balancedType(member[3])) {
|
||
complete.push(member[1]);
|
||
if (topLevelNullable(member[3])) nullable.push(member[1]);
|
||
}
|
||
}
|
||
const optionalFields = members.filter((name) => !required.includes(name));
|
||
result.set(match[1], {
|
||
kind: 'struct',
|
||
members,
|
||
required,
|
||
nullable,
|
||
complete,
|
||
optionalFields,
|
||
});
|
||
}
|
||
const unionPattern =
|
||
/export type (GameDistribution\w+|AdminGameReview\w*|Creator\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') +
|
||
'\n' +
|
||
fs.readFileSync(ADMIN_RUST_FILE, 'utf8') +
|
||
'\n' +
|
||
fs.readFileSync(CREATOR_RUST_FILE, 'utf8'),
|
||
);
|
||
const ts = tsDefinitions(
|
||
fs.readFileSync(TS_FILE, 'utf8') +
|
||
'\n' +
|
||
fs.readFileSync(ADMIN_TS_FILE, 'utf8') +
|
||
'\n' +
|
||
fs.readFileSync(CREATOR_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 侧补上,要么先确认真实响应不再发出)`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 可空性一致性:TS 声明 `| null` 时 Rust 必须是 `Option`;Rust 是 `Option` 时 TS 必须可空(`| null`)
|
||
// 或可省略(`?`)。只比对两边都能完整解析(非跨行)的字段,避免误报。
|
||
for (const [rustName, tsName] of PAIRS) {
|
||
const rustDefinition = rust.get(rustName);
|
||
const tsDefinition = ts.get(tsName);
|
||
if (
|
||
!rustDefinition ||
|
||
!tsDefinition ||
|
||
rustDefinition.kind !== 'struct' ||
|
||
tsDefinition.kind !== 'struct'
|
||
) {
|
||
continue;
|
||
}
|
||
for (const field of rustDefinition.optionalFields) {
|
||
if (!tsDefinition.members.includes(field)) continue;
|
||
if (
|
||
!tsDefinition.nullable.includes(field) &&
|
||
!tsDefinition.optionalFields.includes(field)
|
||
) {
|
||
failures.push(
|
||
`${tsName}.${field} 必须可空(\`| null\`)或可选(\`?\`):${rustName} 的该字段是 Option`,
|
||
);
|
||
}
|
||
}
|
||
for (const field of tsDefinition.nullable) {
|
||
if (!rustDefinition.members.includes(field)) continue;
|
||
if (!tsDefinition.complete.includes(field)) continue;
|
||
if (!rustDefinition.optionalFields.includes(field)) {
|
||
failures.push(
|
||
`${tsName}.${field} 允许 null,但 ${rustName} 的该字段不是 Option,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} 个手拼响应类型已登记`,
|
||
);
|