Files
Genarrative/scripts/check-game-distribution-dto-parity.mjs
suzmii 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
fix(游戏共创): 血缘摘要的父作者名改为读时联账号表兜底(修真实库 parentAuthorName 为 null)
真实库 `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`。
2026-10-07 00:43:54 +08:00

805 lines
32 KiB
JavaScript
Raw Permalink 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
// 检查游戏分发与创作者公开查询的 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} 个手拼响应类型已登记`,
);