test(s3): add game logic consumer contract

This commit is contained in:
zizi 2026-06-04 22:44:14 +08:00
parent 9fe3e277ba
commit b0fe408bcb
2 changed files with 618 additions and 0 deletions

View File

@ -0,0 +1,503 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RuntimeSdkContractVersion } from "../../../../../packages/shared-contracts/src/runtime-sdk-contract.js";
import { validateAndWriteGameLogicModuleArtifact } from "./game-logic-validator.js";
import {
FileSystemGameLogicArtifactRepository,
GameLogicArtifactRepositoryError,
checksumText,
isRepositoryRelativePath,
type GameLogicArtifactRepository,
type ValidationReport
} from "./artifact-repository.js";
import { GameLogicCompilerService, type GameLogicModuleDescriptor } from "./index.js";
import type { GameConfig } from "../game-config/index.js";
const tempRoots: string[] = [];
const buildInputPath = "apps/api/src/modules/game-logic/fixtures/task6-light.game-config.json";
const alternateCheckedFilePath = "dist/game-logic/task6-consumer/other.mjs";
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("S3 S4/S5 mock consumer contract", () => {
it("S4 web builder mock reads descriptor routing fields from repository-backed artifact", async () => {
const context = await createConsumerContext();
await expect(consumeForS4WebBuilder(context.repository, context.descriptor)).resolves.toMatchObject({
status: "accepted",
artifactPath: context.descriptor.artifactPath,
validationReportId: context.descriptor.validationReportId,
runtimeSdkContractVersion: RuntimeSdkContractVersion,
checksum: context.descriptor.checksum
});
});
it("S4 web builder mock rejects missing runtimeSdkContractVersion", async () => {
const context = await createConsumerContext({ descriptorOverrides: { runtimeSdkContractVersion: undefined } });
await expect(consumeForS4WebBuilder(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "S4_RUNTIME_SDK_CONTRACT_VERSION_MISSING",
path: "runtimeSdkContractVersion"
});
});
it("S5 converter mock reads sourceRef, exportSignature and buildGraphId from repository-backed artifact", async () => {
const context = await createConsumerContext();
await expect(consumeForS5Converter(context.repository, context.descriptor)).resolves.toMatchObject({
status: "accepted",
sourceRef: context.descriptor.sourceRef,
exportSignature: context.descriptor.exportSignature,
buildGraphId: context.descriptor.buildGraphId,
checksum: context.descriptor.checksum
});
});
it("S5 converter mock rejects checksum mismatch with stable reasonCode", async () => {
const context = await createConsumerContext({ descriptorOverrides: { checksum: checksumText("descriptor tamper") } });
await expect(consumeForS5Converter(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "VALIDATION_REPORT_CHECKSUM_MISMATCH",
path: "validationReportId"
});
});
it("S5 converter mock rejects descriptor validationStatus self-report before trusting report", async () => {
const context = await createConsumerContext({
writeReport: false,
descriptorOverrides: { validationStatus: "validated" }
});
await expect(consumeForS5Converter(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "GAME_LOGIC_VALIDATION_STATUS_SELF_REPORTED",
path: "validationStatus"
});
});
it.each([
["S4", consumeForS4WebBuilder],
["S5", consumeForS5Converter]
] as const)("%s mock rejects missing ValidationReport", async (_name, consume) => {
const context = await createConsumerContext({ writeReport: false });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "GAME_LOGIC_VALIDATION_REPORT_MISSING",
path: "validationReportId"
});
});
it.each([
["S4", consumeForS4WebBuilder],
["S5", consumeForS5Converter]
] as const)("%s mock rejects ValidationReport.result != passed", async (_name, consume) => {
const context = await createConsumerContext({ reportOverrides: { result: "failed" } });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "VALIDATION_REPORT_FAILED",
path: "validationReportId"
});
});
it.each([
["S4", consumeForS4WebBuilder],
["S5", consumeForS5Converter]
] as const)("%s mock rejects ValidationReport missing validatorVersion", async (_name, consume) => {
const context = await createConsumerContext({ omitReportValidatorVersion: true });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "VALIDATION_REPORT_VALIDATOR_VERSION_MISSING",
path: "validatorVersion"
});
});
it.each([
["S4", consumeForS4WebBuilder, "checksum", { checksum: checksumText("report checksum tamper") }, "VALIDATION_REPORT_CHECKSUM_MISMATCH", "validationReportId"],
["S4", consumeForS4WebBuilder, "sourceHash", { sourceHash: checksumText("source hash tamper") }, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath"],
["S4", consumeForS4WebBuilder, "artifactPath", { artifactPath: "dist/game-logic/other/logic.mjs" }, "VALIDATION_REPORT_ARTIFACT_PATH_MISMATCH", "validationReportId"],
["S5", consumeForS5Converter, "checksum", { checksum: checksumText("report checksum tamper") }, "VALIDATION_REPORT_CHECKSUM_MISMATCH", "validationReportId"],
["S5", consumeForS5Converter, "sourceHash", { sourceHash: checksumText("source hash tamper") }, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath"],
["S5", consumeForS5Converter, "artifactPath", { artifactPath: "dist/game-logic/other/logic.mjs" }, "VALIDATION_REPORT_ARTIFACT_PATH_MISMATCH", "validationReportId"]
] as const)("%s mock rejects ValidationReport %s mismatch", async (_name, consume, _field, reportOverrides, reasonCode, failurePath) => {
const context = await createConsumerContext({ reportOverrides });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode,
path: failurePath
});
});
it.each([
["S4", consumeForS4WebBuilder],
["S5", consumeForS5Converter]
] as const)("%s mock rejects artifact checksum mismatch after repository artifact is tampered", async (_name, consume) => {
const context = await createConsumerContext();
await context.repository.writeLogicArtifact({ artifactPath: context.descriptor.artifactPath, source: "export const tampered = true;" });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "GAME_LOGIC_ARTIFACT_CHECKSUM_MISMATCH",
path: "artifactPath"
});
});
it.each([
["S4", consumeForS4WebBuilder],
["S5", consumeForS5Converter]
] as const)("%s mock rejects ValidationReport checkedFiles missing descriptor artifactPath", async (_name, consume) => {
const context = await createConsumerContext({ rawReportOverrides: { checkedFiles: [alternateCheckedFilePath] } });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "VALIDATION_REPORT_CHECKED_FILES_MISSING_ARTIFACT",
path: "checkedFiles"
});
});
it.each([
["S4", consumeForS4WebBuilder],
["S5", consumeForS5Converter]
] as const)("%s mock reads ValidationReport and artifact through GameLogicArtifactRepository", async (_name, consume) => {
const context = await createConsumerContext();
const repository = trackRepositoryReads(context.repository);
await expect(consume(repository, context.descriptor)).resolves.toMatchObject({ status: "accepted" });
expect(repository.readValidationReport).toHaveBeenCalledWith(context.descriptor.validationReportId);
expect(repository.resolveLogicArtifact).toHaveBeenCalledWith(context.descriptor.sourceRef.buildInputPath);
expect(repository.resolveLogicArtifact).toHaveBeenCalledWith(context.descriptor.artifactPath);
});
it.each([
["S4", consumeForS4WebBuilder, "/tmp/logic.mjs"],
["S4", consumeForS4WebBuilder, "../escape/logic.mjs"],
["S5", consumeForS5Converter, "/tmp/logic.mjs"],
["S5", consumeForS5Converter, "../escape/logic.mjs"]
] as const)("%s mock rejects unsafe descriptor artifactPath %s", async (_name, consume, artifactPath) => {
const context = await createConsumerContext({ descriptorOverrides: { artifactPath } });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "GAME_LOGIC_ARTIFACT_PATH_UNSAFE",
path: "artifactPath"
});
});
it.each([
["S4", consumeForS4WebBuilder, "/tmp/logic.mjs"],
["S4", consumeForS4WebBuilder, "../escape/logic.mjs"],
["S5", consumeForS5Converter, "/tmp/logic.mjs"],
["S5", consumeForS5Converter, "../escape/logic.mjs"]
] as const)("%s mock rejects unsafe ValidationReport.checkedFiles %s", async (_name, consume, checkedFile) => {
const context = await createConsumerContext({ rawReportOverrides: { checkedFiles: [checkedFile] } });
await expect(consume(context.repository, context.descriptor)).resolves.toMatchObject({
status: "rejected",
reasonCode: "GAME_LOGIC_ARTIFACT_PATH_UNSAFE"
});
});
});
type ConsumerName = "S4" | "S5";
type MockConsumerResult =
| {
readonly status: "accepted";
readonly consumer: ConsumerName;
readonly artifactPath: string;
readonly validationReportId: string;
readonly runtimeSdkContractVersion: string;
readonly checksum: string;
readonly artifactSource: string;
readonly sourceRef?: GameLogicModuleDescriptor["sourceRef"];
readonly exportSignature?: GameLogicModuleDescriptor["exportSignature"];
readonly buildGraphId?: string;
}
| {
readonly status: "rejected";
readonly consumer: ConsumerName;
readonly reasonCode: string;
readonly path?: string;
readonly details?: Record<string, unknown>;
};
type UnsafeMutableDescriptor = Omit<GameLogicModuleDescriptor, "runtimeSdkContractVersion"> & {
readonly runtimeSdkContractVersion?: string;
readonly validationStatus?: unknown;
};
type ConsumerContextOptions = {
readonly writeReport?: boolean;
readonly descriptorOverrides?: Record<string, unknown>;
readonly reportOverrides?: Partial<ValidationReport>;
readonly rawReportOverrides?: Record<string, unknown>;
readonly omitReportValidatorVersion?: boolean;
};
type ConsumerContext = {
readonly repository: FileSystemGameLogicArtifactRepository;
readonly descriptor: UnsafeMutableDescriptor;
readonly report: ValidationReport | null;
};
async function consumeForS4WebBuilder(repository: GameLogicArtifactRepository, descriptor: UnsafeMutableDescriptor): Promise<MockConsumerResult> {
const selfReport = rejectDescriptorSelfReport("S4", descriptor);
if (selfReport !== null) return selfReport;
if (typeof descriptor.runtimeSdkContractVersion !== "string" || descriptor.runtimeSdkContractVersion.length === 0) {
return rejected("S4", "S4_RUNTIME_SDK_CONTRACT_VERSION_MISSING", "runtimeSdkContractVersion");
}
if (descriptor.runtimeSdkContractVersion !== RuntimeSdkContractVersion) {
return rejected("S4", "S4_RUNTIME_SDK_CONTRACT_VERSION_INVALID", "runtimeSdkContractVersion", {
expected: RuntimeSdkContractVersion,
actual: descriptor.runtimeSdkContractVersion
});
}
const verified = await readVerifiedConsumerArtifact("S4", repository, descriptor);
if (verified.status === "rejected") return verified;
return {
status: "accepted",
consumer: "S4",
artifactPath: descriptor.artifactPath,
validationReportId: descriptor.validationReportId,
runtimeSdkContractVersion: descriptor.runtimeSdkContractVersion,
checksum: descriptor.checksum,
artifactSource: verified.artifactSource
};
}
async function consumeForS5Converter(repository: GameLogicArtifactRepository, descriptor: UnsafeMutableDescriptor): Promise<MockConsumerResult> {
const selfReport = rejectDescriptorSelfReport("S5", descriptor);
if (selfReport !== null) return selfReport;
if (!isSourceRefReadable(descriptor.sourceRef)) return rejected("S5", "S5_SOURCE_REF_MISSING", "sourceRef");
if (!isExportSignatureReadable(descriptor.exportSignature)) return rejected("S5", "S5_EXPORT_SIGNATURE_MISSING", "exportSignature");
if (typeof descriptor.buildGraphId !== "string" || descriptor.buildGraphId.length === 0) return rejected("S5", "S5_BUILD_GRAPH_ID_MISSING", "buildGraphId");
const verified = await readVerifiedConsumerArtifact("S5", repository, descriptor);
if (verified.status === "rejected") return verified;
return {
status: "accepted",
consumer: "S5",
artifactPath: descriptor.artifactPath,
validationReportId: descriptor.validationReportId,
runtimeSdkContractVersion: descriptor.runtimeSdkContractVersion ?? "",
checksum: descriptor.checksum,
artifactSource: verified.artifactSource,
sourceRef: descriptor.sourceRef,
exportSignature: descriptor.exportSignature,
buildGraphId: descriptor.buildGraphId
};
}
async function readVerifiedConsumerArtifact(
consumer: ConsumerName,
repository: GameLogicArtifactRepository,
descriptor: UnsafeMutableDescriptor
): Promise<MockConsumerResult | { readonly status: "accepted"; readonly artifactSource: string }> {
if (!isRepositoryRelativePath(descriptor.artifactPath)) return rejected(consumer, "GAME_LOGIC_ARTIFACT_PATH_UNSAFE", "artifactPath");
if (!isRepositoryRelativePath(descriptor.sourceRef.buildInputPath)) return rejected(consumer, "GAME_LOGIC_ARTIFACT_PATH_UNSAFE", "sourceRef.buildInputPath");
const reportResult = await readReportThroughRepository(consumer, repository, descriptor.validationReportId);
if (reportResult.status === "rejected") return reportResult;
const report = reportResult.report;
if (report.result !== "passed") return rejected(consumer, "VALIDATION_REPORT_FAILED", "validationReportId", { validationReportId: report.id });
if (typeof report.validatorVersion !== "string" || report.validatorVersion.length === 0) {
return rejected(consumer, "VALIDATION_REPORT_VALIDATOR_VERSION_MISSING", "validatorVersion");
}
if (report.artifactPath !== descriptor.artifactPath) {
return rejected(consumer, "VALIDATION_REPORT_ARTIFACT_PATH_MISMATCH", "validationReportId", {
descriptorArtifactPath: descriptor.artifactPath,
reportArtifactPath: report.artifactPath
});
}
if (!report.checkedFiles.includes(descriptor.artifactPath)) {
return rejected(consumer, "VALIDATION_REPORT_CHECKED_FILES_MISSING_ARTIFACT", "checkedFiles", {
artifactPath: descriptor.artifactPath,
checkedFiles: report.checkedFiles
});
}
if (report.checksum !== descriptor.checksum) {
return rejected(consumer, "VALIDATION_REPORT_CHECKSUM_MISMATCH", "validationReportId", {
descriptorChecksum: descriptor.checksum,
reportChecksum: report.checksum
});
}
const sourceInput = await resolveArtifactThroughRepository(consumer, repository, descriptor.sourceRef.buildInputPath);
if (sourceInput.status === "rejected") return sourceInput;
if (report.sourceHash !== sourceInput.checksum) {
return rejected(consumer, "VALIDATION_REPORT_SOURCE_HASH_MISMATCH", "sourceRef.buildInputPath", {
expectedSourceHash: sourceInput.checksum,
reportSourceHash: report.sourceHash
});
}
const artifact = await resolveArtifactThroughRepository(consumer, repository, descriptor.artifactPath);
if (artifact.status === "rejected") return artifact;
if (artifact.checksum !== descriptor.checksum) {
return rejected(consumer, "GAME_LOGIC_ARTIFACT_CHECKSUM_MISMATCH", "artifactPath", {
descriptorChecksum: descriptor.checksum,
artifactChecksum: artifact.checksum
});
}
return { status: "accepted", artifactSource: artifact.source };
}
async function readReportThroughRepository(
consumer: ConsumerName,
repository: GameLogicArtifactRepository,
validationReportId: string
): Promise<{ readonly status: "accepted"; readonly report: ValidationReport } | MockConsumerResult> {
try {
const report = await repository.readValidationReport(validationReportId);
if (report === null) return rejected(consumer, "GAME_LOGIC_VALIDATION_REPORT_MISSING", "validationReportId", { validationReportId });
return { status: "accepted", report };
} catch (error) {
return rejectedFromRepositoryError(consumer, error, "validationReportId");
}
}
async function resolveArtifactThroughRepository(
consumer: ConsumerName,
repository: GameLogicArtifactRepository,
artifactPath: string
): Promise<{ readonly status: "accepted"; readonly source: string; readonly checksum: string } | MockConsumerResult> {
try {
return { status: "accepted", ...(await repository.resolveLogicArtifact(artifactPath)) };
} catch (error) {
return rejectedFromRepositoryError(consumer, error, "artifactPath");
}
}
function rejectDescriptorSelfReport(consumer: ConsumerName, descriptor: UnsafeMutableDescriptor): MockConsumerResult | null {
// descriptor 是不可信上游输入;消费者必须先拒绝自报 validated再读取 validator-owned report。
if (Object.prototype.hasOwnProperty.call(descriptor, "validationStatus")) {
return rejected(consumer, "GAME_LOGIC_VALIDATION_STATUS_SELF_REPORTED", "validationStatus");
}
return null;
}
function rejectedFromRepositoryError(consumer: ConsumerName, error: unknown, fallbackPath: string): MockConsumerResult {
if (error instanceof GameLogicArtifactRepositoryError) {
return rejected(consumer, error.reasonCode, error.path ?? fallbackPath);
}
throw error;
}
function rejected(consumer: ConsumerName, reasonCode: string, failurePath: string, details?: Record<string, unknown>): MockConsumerResult {
return details === undefined
? { status: "rejected", consumer, reasonCode, path: failurePath }
: { status: "rejected", consumer, reasonCode, path: failurePath, details };
}
async function createConsumerContext(options: ConsumerContextOptions = {}): Promise<ConsumerContext> {
const repository = await createRepository();
const compilerResult = await new GameLogicCompilerService(repository).compileGameLogicModule(createLightGameConfig(), { buildInputPath });
let report: ValidationReport | null = null;
if (options.writeReport !== false) {
await expect(validateAndWriteGameLogicModuleArtifact(repository, compilerResult.descriptor)).resolves.toEqual({ status: "valid" });
report = await repository.readValidationReport(compilerResult.descriptor.validationReportId);
expect(report).not.toBeNull();
const overriddenReport = applyReportOverrides(report as ValidationReport, options);
await writeReportForFixture(repository, overriddenReport, options.rawReportOverrides !== undefined);
report = overriddenReport;
}
return {
repository,
descriptor: { ...compilerResult.descriptor, ...options.descriptorOverrides } as UnsafeMutableDescriptor,
report
};
}
async function createRepository(): Promise<FileSystemGameLogicArtifactRepository> {
const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s3-task6-"));
tempRoots.push(root);
return new FileSystemGameLogicArtifactRepository({ root });
}
function applyReportOverrides(report: ValidationReport, options: ConsumerContextOptions): ValidationReport {
const merged = { ...report, ...options.reportOverrides, ...options.rawReportOverrides } as ValidationReport & Record<string, unknown>;
if (options.omitReportValidatorVersion === true) delete merged.validatorVersion;
return merged as ValidationReport;
}
async function writeReportForFixture(repository: FileSystemGameLogicArtifactRepository, report: ValidationReport, writeRaw: boolean): Promise<void> {
if (!writeRaw) {
await repository.writeValidationReport(report);
return;
}
// raw report 只用于模拟磁盘中已存在的恶意/损坏 report验证 consumer 仍通过 repository read path fail-closed。
const reportPath = path.join(repository.root, "reports", `${report.id}.json`);
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
}
function trackRepositoryReads(repository: FileSystemGameLogicArtifactRepository): GameLogicArtifactRepository & {
readonly readValidationReport: ReturnType<typeof vi.fn<GameLogicArtifactRepository["readValidationReport"]>>;
readonly resolveLogicArtifact: ReturnType<typeof vi.fn<GameLogicArtifactRepository["resolveLogicArtifact"]>>;
} {
return {
writeLogicArtifact: repository.writeLogicArtifact.bind(repository),
writeValidationReport: repository.writeValidationReport.bind(repository),
readValidationReport: vi.fn(repository.readValidationReport.bind(repository)),
resolveLogicArtifact: vi.fn(repository.resolveLogicArtifact.bind(repository))
};
}
function isSourceRefReadable(value: unknown): value is GameLogicModuleDescriptor["sourceRef"] {
return (
typeof value === "object" &&
value !== null &&
typeof (value as GameLogicModuleDescriptor["sourceRef"]).gameConfigId === "string" &&
typeof (value as GameLogicModuleDescriptor["sourceRef"]).templateVersion === "string" &&
typeof (value as GameLogicModuleDescriptor["sourceRef"]).buildInputPath === "string"
);
}
function isExportSignatureReadable(value: unknown): value is GameLogicModuleDescriptor["exportSignature"] {
return (
typeof value === "object" &&
value !== null &&
["init", "update", "render", "handleInput", "onPause", "onResume"].every((entrypoint) => typeof (value as Record<string, unknown>)[entrypoint] === "string")
);
}
function createLightGameConfig(): GameConfig {
return {
id: "game-config-task6-consumer",
sourceGameIrArtifactId: "game-ir-artifact-task6-consumer",
sourceGameIrChecksum: "sha256:game-ir-artifact-task6-consumer",
projectId: "project-task6-consumer",
versionId: "version-task6-consumer",
templateType: "light_collect",
world: {
id: "world-task6-consumer",
type: "single_screen",
loop: {
playerAction: "tap_collect",
spawnRule: "spawn_star_every_2_seconds",
scoreResourceId: "star",
winCondition: "collect_20_stars"
}
},
systems: [{ id: "light-loop-system", type: "light_loop", enabled: true, tickIntervalMs: 1000 }],
resources: [{ id: "star", initial: 0, min: 0, max: 20, kind: "score" }],
controls: [{ id: "tap-collect", action: "collect", inputType: "touch", pointerRegion: { x: 0, y: 0, width: 320, height: 180 } }],
objectives: [{ id: "collect-20-stars", kind: "resource_at_least", target: "star", value: 20 }]
};
}

View File

@ -0,0 +1,115 @@
# 2026-06-04 S3 Task6 消费者合同
## 结论
S3 Task6 `Add S4/S5 Mock Consumer Tests` 已完成实现、controller 自测,并通过 fresh review gate
- spec compliance reviewPASS无 Critical / Important。
- quality / feasibility reviewPASS无 Critical / Important。
可以提交 Task6并在提交后进入 S3 Task7`Write Runtime And Conversion Backend Presearch Note`
## 变更范围
- `apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts`
未修改 production code、shared contracts、scope gate、package exports也未实现真实 S4/S5 runtime/conversion、feed、deploy、routes、UI 或 Task7 presearch note。
## 子代理与 Review Gate
| Role | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementer | `019e9306-1e3a-7a01-ac94-fc385e95e830` | DONE | 无warning 为既有 `pg@9` deprecation warning |
| spec compliance review | `019e9313-2db5-7483-babe-d31918146bab` | PASS | 无 Critical / Important |
| quality / feasibility review | `019e9313-8293-7c01-9c63-546167503a15` | PASS | 无 Critical / Important |
## 实现摘要
- 新增 test-local S4 web builder mock consumer。
- 新增 test-local S5 converter mock consumer。
- 两个 mock consumer 都只通过 `GameLogicArtifactRepository.readValidationReport``resolveLogicArtifact` 读取 report、source input 和 artifact。
- 测试复用:
- Task4 `GameLogicCompilerService` 生成 descriptor/artifact。
- Task5 `validateAndWriteGameLogicModuleArtifact` 写 validator-owned passed report。
- 覆盖 S4
- 读取 `artifactPath``validationReportId``runtimeSdkContractVersion``checksum`
- 拒绝 missing `runtimeSdkContractVersion`
- 覆盖 S5
- 读取 `sourceRef``exportSignature``buildGraphId`
- 拒绝 checksum mismatch。
- 拒绝 descriptor `validationStatus` self-report。
- 覆盖 S4/S5 共同 gate
- missing `ValidationReport`
- `ValidationReport.result != "passed"`
- missing `validatorVersion`
- report checksum mismatch。
- report sourceHash mismatch。
- report artifactPath mismatch。
- artifact tamper 后 checksum mismatch。
- checkedFiles missing descriptor artifactPath。
- descriptor artifactPath absolute path 和 `../` traversal。
- report checkedFiles absolute path 和 `../` traversal。
## Controller 自测
已运行并通过:
```bash
test -f apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts
pnpm --filter @huijing/api test -- src/modules/game-logic/game-logic-consumer-contract.spec.ts
pnpm --filter @huijing/api typecheck
pnpm check:s3-scope
git diff --check
```
关键输出:
```text
Test Files 21 passed (21)
Tests 212 passed (212)
S3 scope check passed.
```
`pnpm --filter @huijing/api test -- src/modules/game-logic/game-logic-consumer-contract.spec.ts` 仍输出既有 `pg@9` deprecation warning退出码为 0。
## Review Evidence
Spec reviewer 额外确认:
```text
当前实现范围tracked 源码 diff 为空;未跟踪文件仅有 game-logic-consumer-contract.spec.ts。
未发现真实 S4/S5 runtime/conversion/feed/deploy/routes/UI 或 Task7 presearch note 实现。
Task6 测试覆盖 S4/S5 mock 读取 descriptor 字段、拒绝 missing/failed/malformed/mismatched report、descriptor self-report、unsafe artifactPath 与 checkedFiles并验证通过 readValidationReport 和 resolveLogicArtifact 读取 repository 数据。
```
Quality reviewer 额外确认:
```text
S4/S5 mock consumer 都通过 repository 读取 ValidationReport、source input 和 artifact。
S4/S5 有角色差异断言。
raw report 直写仅在测试 fixture 中模拟恶意/损坏磁盘 report。
指定 production 文件与 package/export 文件无 diff。
```
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts` | `1d005e3ca20df8d8b65f8155d567d08def73ffc8e0ab74978253cd45e254a62e` |
| `docs/superpowers/plans/2026-05-31-mvp-S3-simulation-slice.md` | `cbc7fb2afaab3cf64b578984b9def7ef265a46aa07029b732ac4ecbc2d68b76a` |
| `docs/superpowers/specs/2026-05-31-mvp-S3-simulation-slice-design.md` | `08abff96b9a1a7b7680e71678f5c479edab24467dd10185c0b0b0e29ea7e613e` |
## Git Status Snapshot
写入本文档前 `git status --short --branch --untracked-files=all`
```text
## codex/s1-task9-s2-prep...origin/codex/s1-task9-s2-prep [ahead 7]
?? apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts
```
## Residual Notes
- Task7 是文档型 runtime/conversion backend presearch note不要把 Task6 mock consumer tests 当成真实 S4/S5 implementation。
- 后续 consumer 仍必须只信 repository 中 validator-owned `ValidationReport.result="passed"`,不能信 descriptor 自报。
- 后续每个 task 继续使用 fresh implementer + controller 自测 + fresh spec/quality review实现子代理等待上限 30 分钟review 子代理等待上限 15 分钟。