diff --git a/apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts b/apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts new file mode 100644 index 00000000..6734c118 --- /dev/null +++ b/apps/api/src/modules/game-logic/game-logic-consumer-contract.spec.ts @@ -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; + }; + +type UnsafeMutableDescriptor = Omit & { + readonly runtimeSdkContractVersion?: string; + readonly validationStatus?: unknown; +}; + +type ConsumerContextOptions = { + readonly writeReport?: boolean; + readonly descriptorOverrides?: Record; + readonly reportOverrides?: Partial; + readonly rawReportOverrides?: Record; + readonly omitReportValidatorVersion?: boolean; +}; + +type ConsumerContext = { + readonly repository: FileSystemGameLogicArtifactRepository; + readonly descriptor: UnsafeMutableDescriptor; + readonly report: ValidationReport | null; +}; + +async function consumeForS4WebBuilder(repository: GameLogicArtifactRepository, descriptor: UnsafeMutableDescriptor): Promise { + 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 { + 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 { + 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): MockConsumerResult { + return details === undefined + ? { status: "rejected", consumer, reasonCode, path: failurePath } + : { status: "rejected", consumer, reasonCode, path: failurePath, details }; +} + +async function createConsumerContext(options: ConsumerContextOptions = {}): Promise { + 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 { + 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; + if (options.omitReportValidatorVersion === true) delete merged.validatorVersion; + return merged as ValidationReport; +} + +async function writeReportForFixture(repository: FileSystemGameLogicArtifactRepository, report: ValidationReport, writeRaw: boolean): Promise { + 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>; + readonly resolveLogicArtifact: ReturnType>; +} { + 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)[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 }] + }; +} diff --git a/docs/memorys/2026-06-04-S3Task6消费者合同.md b/docs/memorys/2026-06-04-S3Task6消费者合同.md new file mode 100644 index 00000000..57fffdbf --- /dev/null +++ b/docs/memorys/2026-06-04-S3Task6消费者合同.md @@ -0,0 +1,115 @@ +# 2026-06-04 S3 Task6 消费者合同 + +## 结论 + +S3 Task6 `Add S4/S5 Mock Consumer Tests` 已完成实现、controller 自测,并通过 fresh review gate: + +- spec compliance review:PASS,无 Critical / Important。 +- quality / feasibility review:PASS,无 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 分钟。