Files
Genarrative/scripts/check-game-distribution-dto-parity.mjs
T
suzmii 6aa2f97410
Project CI / AI game creator shell Rust lane 1/2 (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 Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
feat(游戏共创): 贡献归集与归因(接口 + 契约 + 文档;反洗稿改判搁置)
产品口径:「子代所有的都算父代的」,并要能溯源「多少是子代给的、多少是自己的」。本期**只做计算**,
不含资金/分成结算。

- 递归定义:`inherited(W) = Σ_{c∈直接子代} total(c)`,`total(W) = own(W) + inherited(W)`;每个后代在它的每个
  祖先里只计一次。
- 接口:`GET /api/game-distribution/games/{game_id}/contribution`(Bearer + `no-store`)——**仅该作品作者**:
  未登录/失效 401(中间件)、非作者 403(复用既有 owner-mismatch → FORBIDDEN,不新增第二套鉴权)、
  作品不存在或根自身已软删 404。响应:`{ gameId, own, inherited, total, byGeneration[], directChildren[],
  nodeCount, truncated, truncatedReason }`;指标结构 `ContributionTotals{playCount}` 可扩展(将来加点赞/收藏/收入)。
- 不变量(测试钉住):`inherited == Σ byGeneration.total == Σ directChildren.total`、`total == own + inherited`、
  `nodeCount == Σ byGeneration.gameCount`;`byGeneration` 只含后代(绝对代际,升序)。
- 上限与截断:节点 500 / 深度 32;超限如实标 `truncated: true` + `truncatedReason ∈ {node_limit, depth_limit}`,
  已计入部分仍自洽(与族谱 `truncated` 同约定,不静默给半个数);遍历只用血缘表既有索引,**不新增表/索引**。
- 契约:shared-contracts 4 个 DTO + TS 镜像 + parity(+4 组 → 66 组);7 个生成绑定(含 procedure)。
- 文档:技术方案新增 `§3.11 贡献归集与权益归因`(定义/归因分解/接口/上限与截断/鉴权口径与理由)+ §3.4 路由行 +
  §5.4 实施证据;**任务 B**:反洗稿(相似度校验)从「待拍板」改为「**产品已决定搁置(2026-10-06)**」并从待拍板
  清单移除,结算口径统一为「本期只做贡献归集与归因计算」。
- 测试:module-game-distribution **131 passed**(contribution 12 条,含 A→B→C 三层链路不变量);spacetime-module
  **304 passed / 1 ignored**(+3 结构断言);spacetime-client 43 passed;api-server game_distribution **107 passed**(+3)。

门禁:wasm build 0;`cargo check --all-targets` 0;`SPACETIME_SCHEMA_BASE_REF=9f4c7d76 npm run check:spacetime-schema`
0(98 表);DTO parity 0(66 组 / 17 构建器 / 15 手拼类型);`check:project-bundle-policy-parity` OK;
`check:encoding` 0(5543 files);`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 0;`git diff --check` 0。

注:本块未提交 `scripts/*.mjs`(`check-game-distribution-lineage-e2e.mjs` / `-theme-e2e.mjs` /
`capture-game-lineage-visual.mjs`)——属其它 owner,工作区保留未提交。
2026-10-06 18:05:49 +08:00

796 lines
32 KiB
JavaScript
Raw 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',
],
},
];
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} 个手拼响应类型已登记`,
);