From 5fd4f662173bc275d82f0b5ac7ff13a33a4c2b32 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 5 Jun 2026 16:33:41 +0800 Subject: [PATCH] feat(s5): add devtool import evidence --- .../devtool-import-evidence.spec.ts | 445 ++++++++++++ .../devtool-import-evidence.ts | 668 ++++++++++++++++++ docs/evidence/devtool-import/README.md | 19 + docs/memorys/2026-06-05-S5Task5导入证据.md | 40 ++ scripts/check-scope.mjs | 52 +- 5 files changed, 1219 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts create mode 100644 apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts create mode 100644 docs/evidence/devtool-import/README.md create mode 100644 docs/memorys/2026-06-05-S5Task5导入证据.md diff --git a/apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts b/apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts new file mode 100644 index 00000000..903b4bf4 --- /dev/null +++ b/apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts @@ -0,0 +1,445 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createChannelReadinessNote, + DevToolImportEvidenceRecorder, + selectReferencePlatform, + validateChannelReadinessNoteRecord, + validateDevToolImportBlockerRecord, + validateDevToolImportEvidenceRecord +} from "./devtool-import-evidence.js"; + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("S5 devtool import evidence recorder", () => { + it("records passed DevToolImportEvidence only when paths exist and checksum matches project contents", async () => { + const context = await createEvidenceContext(); + const expectedChecksum = await context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + const evidence = await context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + screenshotPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.png", + createdAt: "2026-06-05T10:00:00.000Z" + }); + + expect(evidence).toMatchObject({ + id: "devtool-import-wechat_minigame-simulation-v1", + result: "passed", + target: "wechat_minigame", + projectChecksum: expectedChecksum + }); + expect(validateDevToolImportEvidenceRecord(evidence, { workspaceRoot: context.root })).toEqual({ ok: true }); + + const evidencePath = path.join(context.root, "docs/evidence/devtool-import/wechat_minigame-simulation-v1.json"); + const written = JSON.parse(await readFile(evidencePath, "utf8")) as typeof evidence; + expect(written).toEqual(evidence); + }); + + it("rejects blocked or failed results as DevToolImportEvidence and requires a blocker record instead", async () => { + const context = await createEvidenceContext(); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + result: "blocked", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: "sha256:not-used", + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ + reasonCode: "DEVTOOL_IMPORT_EVIDENCE_RESULT_INVALID", + path: "result" + }); + + const blocker = await context.recorder.recordBlocker({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + result: "blocked", + reasonCode: "DEVTOOL_UNAVAILABLE", + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + nextAction: "Install WeChat DevTools and rerun import smoke.", + createdAt: "2026-06-05T10:05:00.000Z" + }); + + expect(blocker).toMatchObject({ + id: "devtool-import-blocker-wechat_minigame-simulation-v1", + result: "blocked", + reasonCode: "DEVTOOL_UNAVAILABLE" + }); + expect(validateDevToolImportBlockerRecord(blocker, { workspaceRoot: context.root })).toEqual({ ok: true }); + + const failedBlocker = await context.recorder.recordBlocker({ + target: "wechat_minigame", + gameVersionId: "simulation-v1-failed", + result: "failed", + reasonCode: "IMPORT_FAILED", + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + nextAction: "Fix the rejected project schema and rerun import smoke.", + createdAt: "2026-06-05T10:06:00.000Z" + }); + + expect(failedBlocker).toMatchObject({ + id: "devtool-import-blocker-wechat_minigame-simulation-v1-failed", + result: "failed", + reasonCode: "IMPORT_FAILED" + }); + expect(validateDevToolImportBlockerRecord(failedBlocker, { workspaceRoot: context.root })).toEqual({ ok: true }); + }); + + it("fails closed when log, screenshot, imported project path, or project checksum is invalid", async () => { + const context = await createEvidenceContext(); + const expectedChecksum = await context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/missing.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_LOG_MISSING", path: "logPath" }); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/missing", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PROJECT_MISSING", path: "importedProjectPath" }); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PROJECT_CHECKSUM_MISMATCH", path: "projectChecksum" }); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + screenshotPath: "docs/evidence/devtool-import/missing.png", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_SCREENSHOT_MISSING", path: "screenshotPath" }); + }); + + it("rejects importedProjectPath symlink that points outside the workspace", async () => { + const context = await createEvidenceContext(); + const outside = await createOutsidePath("huijing-s5-devtool-outside-project-"); + await mkdir(path.join(outside, "project/js"), { recursive: true }); + await writeFile(path.join(outside, "project/game.js"), "import './js/runtime-sdk.js';\n", "utf8"); + await writeFile(path.join(outside, "project/js/runtime-sdk.js"), "export const runtimeSdk = {};\n", "utf8"); + await symlink(path.join(outside, "project"), path.join(context.root, "dist/packages/simulation-v1/symlink_project")); + const expectedChecksum = await context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/symlink_project", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "importedProjectPath" }); + }); + + it("rejects logPath symlink that points outside the workspace", async () => { + const context = await createEvidenceContext(); + const outside = await createOutsidePath("huijing-s5-devtool-outside-log-"); + await writeFile(path.join(outside, "outside.log"), "outside log\n", "utf8"); + await symlink(path.join(outside, "outside.log"), path.join(context.root, "docs/evidence/devtool-import/symlink.log")); + const expectedChecksum = await context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/symlink.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "logPath" }); + }); + + it("rejects screenshotPath symlink that points outside the workspace", async () => { + const context = await createEvidenceContext(); + const outside = await createOutsidePath("huijing-s5-devtool-outside-screenshot-"); + await writeFile(path.join(outside, "outside.png"), "outside screenshot\n", "utf8"); + await symlink(path.join(outside, "outside.png"), path.join(context.root, "docs/evidence/devtool-import/symlink.png")); + const expectedChecksum = await context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + screenshotPath: "docs/evidence/devtool-import/symlink.png", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "screenshotPath" }); + }); + + it("rejects symlink entries inside the imported project checksum tree", async () => { + const context = await createEvidenceContext(); + const outside = await createOutsidePath("huijing-s5-devtool-outside-tree-"); + const expectedChecksum = await context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + await writeFile(path.join(outside, "outside.js"), "console.log('outside file');\n", "utf8"); + await mkdir(path.join(outside, "outside-dir"), { recursive: true }); + await symlink(path.join(outside, "outside.js"), path.join(context.root, "dist/packages/simulation-v1/wechat_minigame/js/outside-file.js")); + + await expect(context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame")).rejects.toMatchObject({ + reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", + path: "importedProjectPath.js/outside-file.js" + }); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ + reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", + path: "importedProjectPath.js/outside-file.js" + }); + + await rm(path.join(context.root, "dist/packages/simulation-v1/wechat_minigame/js/outside-file.js")); + await symlink(path.join(outside, "outside-dir"), path.join(context.root, "dist/packages/simulation-v1/wechat_minigame/js/outside-dir")); + + await expect(context.recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame")).rejects.toMatchObject({ + reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", + path: "importedProjectPath.js/outside-dir" + }); + + await expect( + context.recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ + reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", + path: "importedProjectPath.js/outside-dir" + }); + }); + + it("fails closed when evidenceRoot is a workspace symlink to an outside output directory", async () => { + const context = await createEvidenceContext(); + const outside = await createOutsidePath("huijing-s5-devtool-outside-output-"); + const symlinkRoot = "docs/evidence/devtool-output-link"; + await symlink(outside, path.join(context.root, symlinkRoot)); + const recorder = new DevToolImportEvidenceRecorder({ workspaceRoot: context.root, evidenceRoot: symlinkRoot }); + const expectedChecksum = await recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + await expect( + recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "evidenceRoot" }); + await expect(readFile(path.join(outside, "wechat_minigame-simulation-v1.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + + await expect( + recorder.recordBlocker({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + result: "blocked", + reasonCode: "DEVTOOL_UNAVAILABLE", + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + nextAction: "Install WeChat DevTools and rerun import smoke.", + createdAt: "2026-06-05T10:05:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "evidenceRoot" }); + await expect(readFile(path.join(outside, "wechat_minigame-simulation-v1.blocker.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT" + }); + }); + + it("fails closed when evidenceRoot parent chain contains a symlink", async () => { + const context = await createEvidenceContext(); + const outside = await createOutsidePath("huijing-s5-devtool-outside-parent-"); + const symlinkParent = "docs/evidence-parent-link"; + const evidenceRoot = `${symlinkParent}/devtool-import`; + await symlink(outside, path.join(context.root, symlinkParent)); + const recorder = new DevToolImportEvidenceRecorder({ workspaceRoot: context.root, evidenceRoot }); + const expectedChecksum = await recorder.checksumProjectDirectory("dist/packages/simulation-v1/wechat_minigame"); + + await expect( + recorder.recordPassedEvidence({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + referencePlatformReason: "wechat_default", + toolName: "WeChat DevTools", + toolVersion: "1.06.2504010", + importedProjectPath: "dist/packages/simulation-v1/wechat_minigame", + projectChecksum: expectedChecksum, + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + createdAt: "2026-06-05T10:00:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "evidenceRoot" }); + await expect(readFile(path.join(outside, "devtool-import/wechat_minigame-simulation-v1.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT" + }); + + await expect( + recorder.recordBlocker({ + target: "wechat_minigame", + gameVersionId: "simulation-v1", + result: "blocked", + reasonCode: "DEVTOOL_UNAVAILABLE", + logPath: "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log", + nextAction: "Install WeChat DevTools and rerun import smoke.", + createdAt: "2026-06-05T10:05:00.000Z" + }) + ).rejects.toMatchObject({ reasonCode: "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED", path: "evidenceRoot" }); + await expect(readFile(path.join(outside, "devtool-import/wechat_minigame-simulation-v1.blocker.json"), "utf8")).rejects.toMatchObject({ + code: "ENOENT" + }); + }); + + it("selects business primary target, then highest known technical risk, then WeChat default", () => { + expect( + selectReferencePlatform({ + businessPrimaryTarget: "douyin_minigame", + technicalRisks: [ + { target: "wechat_minigame", risk: "known", score: 40 }, + { target: "kuaishou_minigame", risk: "unknown", score: 100 } + ] + }) + ).toEqual({ target: "douyin_minigame", referencePlatformReason: "business_primary" }); + + expect( + selectReferencePlatform({ + technicalRisks: [ + { target: "wechat_minigame", risk: "known", score: 40 }, + { target: "douyin_minigame", risk: "known", score: 80 }, + { target: "kuaishou_minigame", risk: "unknown", score: 100 } + ] + }) + ).toEqual({ target: "douyin_minigame", referencePlatformReason: "highest_technical_risk" }); + + expect( + selectReferencePlatform({ + technicalRisks: [{ target: "kuaishou_minigame", risk: "unknown", score: 100 }] + }) + ).toEqual({ target: "wechat_minigame", referencePlatformReason: "wechat_default" }); + }); + + it("creates ChannelReadinessNote as a non-evidence record that cannot replace import evidence", () => { + const note = createChannelReadinessNote({ + target: "kuaishou_minigame", + gameVersionId: "simulation-v1", + readiness: "future_channel_review_required", + risk: "Kuaishou import risk is tracked separately from S5 passed evidence.", + nextAction: "Collect official channel material in S7/S8.", + createdAt: "2026-06-05T10:15:00.000Z" + }); + + expect(validateChannelReadinessNoteRecord(note)).toEqual({ ok: true }); + expect(validateDevToolImportEvidenceRecord(note, { workspaceRoot: "/" })).toMatchObject({ + ok: false, + reasonCode: "DEVTOOL_IMPORT_EVIDENCE_INVALID" + }); + }); +}); + +type EvidenceContext = { + readonly root: string; + readonly recorder: DevToolImportEvidenceRecorder; +}; + +async function createEvidenceContext(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "huijing-s5-devtool-evidence-")); + tempRoots.push(root); + const recorder = new DevToolImportEvidenceRecorder({ workspaceRoot: root }); + + await mkdir(path.join(root, "dist/packages/simulation-v1/wechat_minigame/js"), { recursive: true }); + await mkdir(path.join(root, "docs/evidence/devtool-import"), { recursive: true }); + await writeFile(path.join(root, "dist/packages/simulation-v1/wechat_minigame/game.js"), "import './js/runtime-sdk.js';\n", "utf8"); + await writeFile(path.join(root, "dist/packages/simulation-v1/wechat_minigame/js/runtime-sdk.js"), "export const runtimeSdk = {};\n", "utf8"); + await writeFile(path.join(root, "docs/evidence/devtool-import/wechat_minigame-simulation-v1.log"), "import passed\n", "utf8"); + await writeFile(path.join(root, "docs/evidence/devtool-import/wechat_minigame-simulation-v1.png"), "fake png bytes\n", "utf8"); + + return { root, recorder }; +} + +async function createOutsidePath(prefix: string): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(root); + return root; +} diff --git a/apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts b/apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts new file mode 100644 index 00000000..df0e8fe1 --- /dev/null +++ b/apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts @@ -0,0 +1,668 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export type MiniGameTarget = "wechat_minigame" | "douyin_minigame" | "kuaishou_minigame"; +export type DevToolImportReferenceReason = "business_primary" | "highest_technical_risk" | "wechat_default"; +export type DevToolImportBlockerReasonCode = "DEVTOOL_UNAVAILABLE" | "IMPORT_FAILED" | "PROJECT_SCHEMA_REJECTED" | "UNKNOWN"; +export type ChannelReadiness = "not_assessed" | "missing_materials" | "future_channel_review_required"; + +export type DevToolImportValidationResult = { ok: true } | { ok: false; reasonCode: string; path?: string }; + +export type DevToolImportEvidence = { + readonly id: string; + readonly target: MiniGameTarget; + readonly gameVersionId: string; + readonly referencePlatformReason: DevToolImportReferenceReason; + readonly result: "passed"; + readonly toolName: string; + readonly toolVersion: string; + readonly importedProjectPath: string; + readonly projectChecksum: string; + readonly logPath: string; + readonly screenshotPath?: string; + readonly createdAt: string; +}; + +export type DevToolImportBlocker = { + readonly id: string; + readonly target: MiniGameTarget; + readonly gameVersionId: string; + readonly result: "blocked" | "failed"; + readonly reasonCode: DevToolImportBlockerReasonCode; + readonly logPath: string; + readonly nextAction: string; + readonly createdAt: string; +}; + +export type ChannelReadinessNote = { + readonly id: string; + readonly target: MiniGameTarget; + readonly gameVersionId: string; + readonly readiness: ChannelReadiness; + readonly risk: string; + readonly nextAction: string; + readonly createdAt?: string; +}; + +export type TechnicalRiskInput = { + readonly target: MiniGameTarget; + readonly risk: "known" | "unknown"; + readonly score: number; +}; + +export type ReferencePlatformSelectionInput = { + readonly businessPrimaryTarget?: MiniGameTarget; + readonly technicalRisks?: readonly TechnicalRiskInput[]; +}; + +export type ReferencePlatformSelection = { + readonly target: MiniGameTarget; + readonly referencePlatformReason: DevToolImportReferenceReason; +}; + +export type DevToolImportEvidenceRecorderOptions = { + readonly workspaceRoot?: string; + readonly evidenceRoot?: string; +}; + +export type RecordPassedDevToolImportEvidenceInput = { + readonly id?: string; + readonly target: MiniGameTarget; + readonly gameVersionId: string; + readonly referencePlatformReason: DevToolImportReferenceReason; + readonly result?: string; + readonly toolName: string; + readonly toolVersion: string; + readonly importedProjectPath: string; + readonly projectChecksum: string; + readonly logPath: string; + readonly screenshotPath?: string; + readonly createdAt: string; +}; + +export type RecordDevToolImportBlockerInput = { + readonly id?: string; + readonly target: MiniGameTarget; + readonly gameVersionId: string; + readonly result?: string; + readonly reasonCode: DevToolImportBlockerReasonCode; + readonly logPath: string; + readonly nextAction: string; + readonly createdAt: string; +}; + +export type CreateChannelReadinessNoteInput = { + readonly id?: string; + readonly target: MiniGameTarget; + readonly gameVersionId: string; + readonly readiness: ChannelReadiness; + readonly risk: string; + readonly nextAction: string; + readonly createdAt?: string; +}; + +type ValidationOptions = { + readonly workspaceRoot?: string; +}; + +type FileChecksum = { + readonly path: string; + readonly checksum: string; + readonly sizeBytes: number; +}; + +const MINI_GAME_TARGETS = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"] as const satisfies readonly MiniGameTarget[]; +const REFERENCE_REASONS = ["business_primary", "highest_technical_risk", "wechat_default"] as const satisfies readonly DevToolImportReferenceReason[]; +const BLOCKER_REASONS = [ + "DEVTOOL_UNAVAILABLE", + "IMPORT_FAILED", + "PROJECT_SCHEMA_REJECTED", + "UNKNOWN" +] as const satisfies readonly DevToolImportBlockerReasonCode[]; +const BLOCKER_RESULTS = ["blocked", "failed"] as const satisfies readonly DevToolImportBlocker["result"][]; +const CHANNEL_READINESS_VALUES = [ + "not_assessed", + "missing_materials", + "future_channel_review_required" +] as const satisfies readonly ChannelReadiness[]; +const DEFAULT_EVIDENCE_ROOT = "docs/evidence/devtool-import"; +const PATH_SYMLINK_UNSUPPORTED_REASON = "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED"; +const PATH_REALPATH_ESCAPE_REASON = "DEVTOOL_IMPORT_PATH_REALPATH_ESCAPE"; + +export class DevToolImportEvidenceError extends Error { + readonly reasonCode: string; + readonly path?: string; + readonly details: Record; + + constructor(reasonCode: string, message: string, details: Record = {}) { + super(message); + this.name = "DevToolImportEvidenceError"; + this.reasonCode = reasonCode; + if (typeof details.path === "string") this.path = details.path; + this.details = details; + } +} + +export class DevToolImportEvidenceRecorder { + private readonly workspaceRoot: string; + private readonly workspaceRootReal: Promise; + private readonly evidenceRoot: string; + + constructor(options: DevToolImportEvidenceRecorderOptions = {}) { + this.workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd()); + this.workspaceRootReal = realpath(this.workspaceRoot); + this.evidenceRoot = options.evidenceRoot ?? DEFAULT_EVIDENCE_ROOT; + assertControlledRelativePath(this.evidenceRoot, "DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "evidenceRoot"); + } + + async checksumProjectDirectory(importedProjectPath: string): Promise { + const projectRoot = await this.resolveExistingDirectory(importedProjectPath, "DEVTOOL_IMPORT_PROJECT_MISSING", "importedProjectPath"); + const files = await collectFileChecksums(projectRoot, await this.workspaceRootReal); + + // 目录 checksum 只由相对路径、文件 sha256 和字节数生成;不包含 mtime,确保同一项目内容跨机器可复算。 + return checksumText(stableJsonText({ files })); + } + + async recordPassedEvidence(input: RecordPassedDevToolImportEvidenceInput): Promise { + const evidence = buildPassedEvidence(input); + const structuralResult = validateDevToolImportEvidenceRecord(evidence, { workspaceRoot: this.workspaceRoot }); + if (!structuralResult.ok) throwValidationFailure(structuralResult, "Invalid DevToolImportEvidence record"); + + // 外部开发者工具导入是人工或工具链交互结果;recorder 只记录已发生的 passed 结果,并用日志/目录/hash 约束证据边界。 + await this.resolveExistingFile(evidence.logPath, "DEVTOOL_IMPORT_LOG_MISSING", "logPath"); + if (evidence.screenshotPath !== undefined) { + await this.resolveExistingFile(evidence.screenshotPath, "DEVTOOL_IMPORT_SCREENSHOT_MISSING", "screenshotPath"); + } + + const actualChecksum = await this.checksumProjectDirectory(evidence.importedProjectPath); + if (actualChecksum !== evidence.projectChecksum) { + throw new DevToolImportEvidenceError("DEVTOOL_IMPORT_PROJECT_CHECKSUM_MISMATCH", "Devtool import project checksum does not match directory contents", { + path: "projectChecksum", + expectedChecksum: evidence.projectChecksum, + actualChecksum + }); + } + + const evidencePath = await this.resolveEvidenceOutputPath(evidence.target, evidence.gameVersionId, "json"); + await atomicWriteFile(evidencePath, stableJsonText(evidence)); + return evidence; + } + + async recordBlocker(input: RecordDevToolImportBlockerInput): Promise { + const blocker = buildBlocker(input); + const structuralResult = validateDevToolImportBlockerRecord(blocker, { workspaceRoot: this.workspaceRoot }); + if (!structuralResult.ok) throwValidationFailure(structuralResult, "Invalid DevToolImportBlocker record"); + + // blocker 解释为什么没有 passed evidence,但不能满足 S5 P0;必须保留日志,方便后续重跑或定位工具链问题。 + await this.resolveExistingFile(blocker.logPath, "DEVTOOL_IMPORT_LOG_MISSING", "logPath"); + + const blockerPath = await this.resolveEvidenceOutputPath(blocker.target, blocker.gameVersionId, "blocker.json"); + await atomicWriteFile(blockerPath, stableJsonText(blocker)); + return blocker; + } + + private async resolveEvidenceOutputPath(target: MiniGameTarget, gameVersionId: string, extension: "json" | "blocker.json"): Promise { + assertSafeFileSegment(gameVersionId, "gameVersionId"); + const evidenceRoot = this.resolveWorkspacePath(this.evidenceRoot, "DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "evidenceRoot"); + const fileName = `${target}-${gameVersionId}.${extension}`; + const resolved = path.resolve(evidenceRoot, fileName); + if (!isInsideOrSame(evidenceRoot, resolved)) { + throw new DevToolImportEvidenceError("DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "Resolved evidence output escaped evidence root", { + path: "gameVersionId", + gameVersionId + }); + } + await this.prepareEvidenceOutputRoot(evidenceRoot); + return resolved; + } + + private async prepareEvidenceOutputRoot(evidenceRoot: string): Promise { + // output root 允许不存在,但创建前必须验证已存在的父链;否则 mkdir/write 会跟随 workspace 内 symlink 写到外部目录。 + await this.assertSafeExistingPathSegments(evidenceRoot, this.evidenceRoot, "evidenceRoot"); + await mkdir(evidenceRoot, { recursive: true }); + await this.assertSafeExistingPathSegments(evidenceRoot, this.evidenceRoot, "evidenceRoot"); + + const stats = await lstat(evidenceRoot); + if (!stats.isDirectory()) { + throw new DevToolImportEvidenceError("DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "Devtool import evidence root is not a directory", { + path: "evidenceRoot", + repositoryPath: this.evidenceRoot + }); + } + } + + private async resolveExistingFile(repositoryPath: string, reasonCode: string, fieldPath: string): Promise { + const resolved = this.resolveWorkspacePath(repositoryPath, reasonCode, fieldPath); + await this.assertNoSymlinkPathSegments(resolved, repositoryPath, fieldPath); + let stats; + try { + stats = await lstat(resolved); + } catch { + throw new DevToolImportEvidenceError(reasonCode, "Required devtool import evidence file is missing", { + path: fieldPath, + repositoryPath + }); + } + if (!stats.isFile()) { + throw new DevToolImportEvidenceError(reasonCode, "Required devtool import evidence path is not a file", { + path: fieldPath, + repositoryPath + }); + } + await this.assertRealPathInsideWorkspace(resolved, repositoryPath, fieldPath); + return resolved; + } + + private async resolveExistingDirectory(repositoryPath: string, reasonCode: string, fieldPath: string): Promise { + const resolved = this.resolveWorkspacePath(repositoryPath, reasonCode, fieldPath); + await this.assertNoSymlinkPathSegments(resolved, repositoryPath, fieldPath); + let stats; + try { + stats = await lstat(resolved); + } catch { + throw new DevToolImportEvidenceError(reasonCode, "Required imported mini-game project directory is missing", { + path: fieldPath, + repositoryPath + }); + } + if (!stats.isDirectory()) { + throw new DevToolImportEvidenceError(reasonCode, "Required imported mini-game project path is not a directory", { + path: fieldPath, + repositoryPath + }); + } + await this.assertRealPathInsideWorkspace(resolved, repositoryPath, fieldPath); + return resolved; + } + + private async assertNoSymlinkPathSegments(resolvedPath: string, repositoryPath: string, fieldPath: string): Promise { + const relativePath = path.relative(this.workspaceRoot, resolvedPath); + let currentPath = this.workspaceRoot; + + // 逐段 lstat,避免 `stat` 或 leaf-only lstat 跟随父目录 symlink 后把 workspace 外路径当成合法证据。 + for (const segment of relativePath.split(path.sep)) { + if (segment.length === 0) continue; + currentPath = path.join(currentPath, segment); + let stats; + try { + stats = await lstat(currentPath); + } catch { + return; + } + if (stats.isSymbolicLink()) { + throw new DevToolImportEvidenceError(PATH_SYMLINK_UNSUPPORTED_REASON, "Devtool import evidence paths do not support symlinks", { + path: fieldPath, + repositoryPath + }); + } + } + } + + private async assertSafeExistingPathSegments(resolvedPath: string, repositoryPath: string, fieldPath: string): Promise { + const relativePath = path.relative(this.workspaceRoot, resolvedPath); + let currentPath = this.workspaceRoot; + + // 输出目录可能部分不存在;只检查已经存在的路径段,遇到第一个缺失段后交给安全 mkdir 创建。 + for (const segment of relativePath.split(path.sep)) { + if (segment.length === 0) continue; + currentPath = path.join(currentPath, segment); + let stats; + try { + stats = await lstat(currentPath); + } catch { + return; + } + if (stats.isSymbolicLink()) { + throw new DevToolImportEvidenceError(PATH_SYMLINK_UNSUPPORTED_REASON, "Devtool import evidence paths do not support symlinks", { + path: fieldPath, + repositoryPath + }); + } + + const currentReal = await realpath(currentPath); + if (!isInsideOrSameReal(await this.workspaceRootReal, currentReal)) { + throw new DevToolImportEvidenceError(PATH_REALPATH_ESCAPE_REASON, "Devtool import evidence real path escaped workspace root", { + path: fieldPath, + repositoryPath, + realPath: currentReal + }); + } + } + } + + private async assertRealPathInsideWorkspace(resolvedPath: string, repositoryPath: string, fieldPath: string): Promise { + const candidateReal = await realpath(resolvedPath); + if (isInsideOrSameReal(await this.workspaceRootReal, candidateReal)) return; + throw new DevToolImportEvidenceError(PATH_REALPATH_ESCAPE_REASON, "Devtool import evidence real path escaped workspace root", { + path: fieldPath, + repositoryPath, + realPath: candidateReal + }); + } + + private resolveWorkspacePath(repositoryPath: string, reasonCode: string, fieldPath: string): string { + assertControlledRelativePath(repositoryPath, reasonCode, fieldPath); + const resolved = path.resolve(this.workspaceRoot, repositoryPath); + if (!isInsideOrSame(this.workspaceRoot, resolved)) { + throw new DevToolImportEvidenceError(reasonCode, "Path escaped workspace root", { path: fieldPath, repositoryPath }); + } + return resolved; + } +} + +export function validateDevToolImportEvidenceRecord(value: unknown, _options: ValidationOptions = {}): DevToolImportValidationResult { + void _options; + if (!isRecord(value)) return invalid("DEVTOOL_IMPORT_EVIDENCE_INVALID", ""); + if ("readiness" in value || "risk" in value) return invalid("DEVTOOL_IMPORT_EVIDENCE_INVALID", ""); + const requiredResult = validateRequiredStringFields(value, "DEVTOOL_IMPORT_EVIDENCE_FIELD_MISSING", [ + "id", + "gameVersionId", + "toolName", + "toolVersion", + "importedProjectPath", + "projectChecksum", + "logPath", + "createdAt" + ]); + if (!requiredResult.ok) return requiredResult; + + const targetResult = validateTarget(value.target); + if (!targetResult.ok) return prefix(targetResult, "target"); + if (!isOneOf(value.referencePlatformReason, REFERENCE_REASONS)) { + return invalid("DEVTOOL_IMPORT_EVIDENCE_REFERENCE_REASON_INVALID", "referencePlatformReason"); + } + // blocked/failed 不是“差一点通过”的 evidence;它们必须写成 blocker,避免 S5/S8 gate 被文字描述绕过。 + if (value.result !== "passed") return invalid("DEVTOOL_IMPORT_EVIDENCE_RESULT_INVALID", "result"); + if (!isSha256Checksum(value.projectChecksum)) return invalid("DEVTOOL_IMPORT_PROJECT_CHECKSUM_INVALID", "projectChecksum"); + if (!isIsoTimestamp(value.createdAt)) return invalid("DEVTOOL_IMPORT_CREATED_AT_INVALID", "createdAt"); + + const pathResult = validateControlledPathFields(value, "DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", [ + "importedProjectPath", + "logPath", + "screenshotPath" + ]); + if (!pathResult.ok) return pathResult; + + return { ok: true }; +} + +export function validateDevToolImportBlockerRecord(value: unknown, _options: ValidationOptions = {}): DevToolImportValidationResult { + void _options; + if (!isRecord(value)) return invalid("DEVTOOL_IMPORT_BLOCKER_INVALID", ""); + const requiredResult = validateRequiredStringFields(value, "DEVTOOL_IMPORT_BLOCKER_FIELD_MISSING", [ + "id", + "gameVersionId", + "logPath", + "nextAction", + "createdAt" + ]); + if (!requiredResult.ok) return requiredResult; + + const targetResult = validateTarget(value.target); + if (!targetResult.ok) return prefix(targetResult, "target"); + if (!isOneOf(value.result, BLOCKER_RESULTS)) return invalid("DEVTOOL_IMPORT_BLOCKER_RESULT_INVALID", "result"); + if (!isOneOf(value.reasonCode, BLOCKER_REASONS)) return invalid("DEVTOOL_IMPORT_BLOCKER_REASON_INVALID", "reasonCode"); + if (!isIsoTimestamp(value.createdAt)) return invalid("DEVTOOL_IMPORT_BLOCKER_CREATED_AT_INVALID", "createdAt"); + + const pathResult = validateControlledPathFields(value, "DEVTOOL_IMPORT_BLOCKER_PATH_INVALID", ["logPath"]); + if (!pathResult.ok) return pathResult; + + return { ok: true }; +} + +export function createChannelReadinessNote(input: CreateChannelReadinessNoteInput): ChannelReadinessNote { + const note = { + id: input.id ?? `readiness-note-${input.target}-${input.gameVersionId}`, + target: input.target, + gameVersionId: input.gameVersionId, + readiness: input.readiness, + risk: input.risk, + nextAction: input.nextAction, + ...(input.createdAt === undefined ? {} : { createdAt: input.createdAt }) + } satisfies ChannelReadinessNote; + + const validation = validateChannelReadinessNoteRecord(note); + if (!validation.ok) throwValidationFailure(validation, "Invalid ChannelReadinessNote record"); + return note; +} + +export function validateChannelReadinessNoteRecord(value: unknown): DevToolImportValidationResult { + if (!isRecord(value)) return invalid("CHANNEL_READINESS_NOTE_INVALID", ""); + const requiredResult = validateRequiredStringFields(value, "CHANNEL_READINESS_NOTE_FIELD_MISSING", [ + "id", + "gameVersionId", + "risk", + "nextAction" + ]); + if (!requiredResult.ok) return requiredResult; + + const targetResult = validateTarget(value.target); + if (!targetResult.ok) return prefix(targetResult, "target"); + if (!isOneOf(value.readiness, CHANNEL_READINESS_VALUES)) return invalid("CHANNEL_READINESS_NOTE_READINESS_INVALID", "readiness"); + if (value.createdAt !== undefined && (!isNonEmptyString(value.createdAt) || !isIsoTimestamp(value.createdAt))) { + return invalid("CHANNEL_READINESS_NOTE_CREATED_AT_INVALID", "createdAt"); + } + + // Channel readiness 是渠道材料/后续审核备注,不包含 import result/tool/checksum,设计上不能替代 DevToolImportEvidence。 + return { ok: true }; +} + +export function selectReferencePlatform(input: ReferencePlatformSelectionInput = {}): ReferencePlatformSelection { + if (input.businessPrimaryTarget !== undefined) { + return { target: input.businessPrimaryTarget, referencePlatformReason: "business_primary" }; + } + + const highestKnownRisk = [...(input.technicalRisks ?? [])] + .filter((risk) => risk.risk === "known" && Number.isFinite(risk.score)) + .sort((left, right) => right.score - left.score)[0]; + if (highestKnownRisk !== undefined) { + return { target: highestKnownRisk.target, referencePlatformReason: "highest_technical_risk" }; + } + + // unknown 风险平台只能形成 blocker/note 或等待实证,不能因为风险高就自动冒充 passed 的参考平台。 + return { target: "wechat_minigame", referencePlatformReason: "wechat_default" }; +} + +function buildPassedEvidence(input: RecordPassedDevToolImportEvidenceInput): DevToolImportEvidence { + const candidate = { + id: input.id ?? `devtool-import-${input.target}-${input.gameVersionId}`, + target: input.target, + gameVersionId: input.gameVersionId, + referencePlatformReason: input.referencePlatformReason, + result: input.result ?? "passed", + toolName: input.toolName, + toolVersion: input.toolVersion, + importedProjectPath: input.importedProjectPath, + projectChecksum: input.projectChecksum, + logPath: input.logPath, + ...(input.screenshotPath === undefined ? {} : { screenshotPath: input.screenshotPath }), + createdAt: input.createdAt + }; + const validation = validateDevToolImportEvidenceRecord(candidate); + if (!validation.ok) throwValidationFailure(validation, "Invalid DevToolImportEvidence record"); + return candidate as DevToolImportEvidence; +} + +function buildBlocker(input: RecordDevToolImportBlockerInput): DevToolImportBlocker { + const candidate = { + id: input.id ?? `devtool-import-blocker-${input.target}-${input.gameVersionId}`, + target: input.target, + gameVersionId: input.gameVersionId, + result: input.result ?? "blocked", + reasonCode: input.reasonCode, + logPath: input.logPath, + nextAction: input.nextAction, + createdAt: input.createdAt + }; + const validation = validateDevToolImportBlockerRecord(candidate); + if (!validation.ok) throwValidationFailure(validation, "Invalid DevToolImportBlocker record"); + return candidate as DevToolImportBlocker; +} + +async function collectFileChecksums(rootPath: string, workspaceRootReal: string): Promise { + const result: FileChecksum[] = []; + await collectFileChecksumsInto(rootPath, workspaceRootReal, "", result); + return result.sort((left, right) => left.path.localeCompare(right.path)); +} + +async function collectFileChecksumsInto(rootPath: string, workspaceRootReal: string, relativeRoot: string, result: FileChecksum[]): Promise { + const entries = await readdir(path.join(rootPath, relativeRoot), { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const relativePath = relativeRoot.length === 0 ? entry.name : `${relativeRoot}/${entry.name}`; + const absolutePath = path.join(rootPath, relativePath); + const fieldPath = `importedProjectPath.${relativePath}`; + const entryStats = await lstat(absolutePath); + if (entryStats.isSymbolicLink()) { + throw new DevToolImportEvidenceError(PATH_SYMLINK_UNSUPPORTED_REASON, "Imported project checksum tree does not support symlinks", { + path: fieldPath, + repositoryPath: relativePath + }); + } + const entryReal = await realpath(absolutePath); + if (!isInsideOrSameReal(workspaceRootReal, entryReal)) { + throw new DevToolImportEvidenceError(PATH_REALPATH_ESCAPE_REASON, "Imported project checksum tree escaped workspace root", { + path: fieldPath, + repositoryPath: relativePath, + realPath: entryReal + }); + } + + if (entryStats.isDirectory()) { + await collectFileChecksumsInto(rootPath, workspaceRootReal, relativePath, result); + } else if (entryStats.isFile()) { + const content = await readFile(absolutePath); + result.push({ + path: relativePath, + checksum: checksumBytes(content), + sizeBytes: entryStats.size + }); + } + } +} + +async function atomicWriteFile(targetPath: string, content: string): Promise { + await mkdir(path.dirname(targetPath), { recursive: true }); + const tempPath = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`); + await writeFile(tempPath, content, "utf8"); + await rename(tempPath, targetPath); +} + +function validateTarget(value: unknown): DevToolImportValidationResult { + return isOneOf(value, MINI_GAME_TARGETS) ? { ok: true } : invalid("MINIGAME_CONVERSION_TARGET_INVALID", ""); +} + +function validateRequiredStringFields( + value: Record, + reasonCode: string, + fields: readonly string[] +): DevToolImportValidationResult { + for (const field of fields) { + if (!isNonEmptyString(value[field])) return invalid(reasonCode, field); + } + return { ok: true }; +} + +function validateControlledPathFields( + value: Record, + reasonCode: string, + fields: readonly string[] +): DevToolImportValidationResult { + for (const field of fields) { + const fieldValue = value[field]; + if (fieldValue === undefined) continue; + if (!isNonEmptyString(fieldValue) || !isControlledRelativePath(fieldValue)) return invalid(reasonCode, field); + } + return { ok: true }; +} + +function assertControlledRelativePath(value: string, reasonCode: string, fieldPath: string): void { + if (!isControlledRelativePath(value)) { + throw new DevToolImportEvidenceError(reasonCode, "Path must be workspace-relative and controlled", { + path: fieldPath, + value + }); + } +} + +function assertSafeFileSegment(value: string, fieldPath: string): void { + if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") { + throw new DevToolImportEvidenceError("DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "Evidence file segment is unsafe", { + path: fieldPath, + value + }); + } +} + +function isControlledRelativePath(value: string): boolean { + if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) { + return false; + } + let decodedValue: string; + try { + decodedValue = decodeURIComponent(value); + } catch { + return false; + } + + for (const candidate of [value, decodedValue]) { + if (candidate.startsWith("/") || candidate.startsWith("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false; + const segments = candidate.split("/"); + if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) return false; + } + return true; +} + +function throwValidationFailure(result: Extract, message: string): never { + throw new DevToolImportEvidenceError(result.reasonCode, message, result.path === undefined ? {} : { path: result.path }); +} + +function prefix(result: DevToolImportValidationResult, pathPrefix: string): DevToolImportValidationResult { + if (result.ok) return result; + return result.path === undefined ? { ...result, path: pathPrefix } : { ...result, path: `${pathPrefix}.${result.path}` }; +} + +function invalid(reasonCode: string, pathValue: string): DevToolImportValidationResult { + return pathValue.length === 0 ? { ok: false, reasonCode } : { ok: false, reasonCode, path: pathValue }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isSha256Checksum(value: unknown): value is string { + return typeof value === "string" && /^sha256:[0-9a-f]{64}$/i.test(value); +} + +function isIsoTimestamp(value: unknown): value is string { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function isOneOf(value: unknown, allowed: readonly T[]): value is T { + return typeof value === "string" && allowed.includes(value as T); +} + +function isInsideOrSame(rootPath: string, candidatePath: string): boolean { + return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${path.sep}`); +} + +function isInsideOrSameReal(rootPath: string, candidatePath: string): boolean { + const relativePath = path.relative(rootPath, candidatePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function stableJsonText(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function checksumText(value: string): string { + return checksumBytes(Buffer.from(value, "utf8")); +} + +function checksumBytes(value: Uint8Array): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} diff --git a/docs/evidence/devtool-import/README.md b/docs/evidence/devtool-import/README.md new file mode 100644 index 00000000..275bfd10 --- /dev/null +++ b/docs/evidence/devtool-import/README.md @@ -0,0 +1,19 @@ +# DevTool Import Evidence + +P0 requires at least one reference-platform import smoke before S5 can claim mini-game import verification. + +Default selection: + +1. business-primary platform; +2. highest known technical-risk platform; +3. WeChat if neither is known. + +Passing evidence is written to `docs/evidence/devtool-import/{target}-{gameVersionId}.json`. + +Passing evidence must include `target`, `gameVersionId`, `referencePlatformReason`, `result="passed"`, `toolName`, `toolVersion`, `importedProjectPath`, `projectChecksum`, `logPath`, and `createdAt`. `screenshotPath` is optional, but if present it must point to an existing file. + +`importedProjectPath` and `logPath` must exist when evidence is recorded. `projectChecksum` must match the stable hash of the imported project directory contents. + +`result="blocked"` or `result="failed"` is not passing evidence. Use `DevToolImportBlocker.result="blocked"` when the tool is unavailable, and `DevToolImportBlocker.result="failed"` when import runs but rejects the project. A blocker explains S5 No-Go and keeps the retry path auditable, but it never satisfies `DevToolImportEvidence`. + +`ChannelReadinessNote` is only for later channel preparation gaps. It cannot replace `DevToolImportEvidence`, `DevToolImportBlocker`, or channel review. diff --git a/docs/memorys/2026-06-05-S5Task5导入证据.md b/docs/memorys/2026-06-05-S5Task5导入证据.md new file mode 100644 index 00000000..34877150 --- /dev/null +++ b/docs/memorys/2026-06-05-S5Task5导入证据.md @@ -0,0 +1,40 @@ +# S5 Task5 导入证据 + +## 结论 + +S5 Task5 已完成 DevTool import evidence recorder、README、scope gate 放行和回归测试。`DevToolImportEvidence` 仍只允许 `result="passed"` 满足 P0;`DevToolImportBlocker` 支持 `blocked | failed`,但不能替代 passed evidence;`ChannelReadinessNote` 只能记录渠道准备风险。 + +## 关键实现 + +- 新增 `apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts` 和对应测试。 +- 新增 `docs/evidence/devtool-import/README.md`,记录 P0 证据要求、参考平台选择顺序、必填字段、checksum、blocker/note 边界。 +- 更新 `scripts/check-scope.mjs`:S5 只允许 `devtool-import-evidence.ts` 和 `devtool-import-evidence.spec.ts` 使用 `DevToolImportEvidence`、`DevToolImportBlocker`、`ChannelReadinessNote` 术语;generic converter/static gate 仍不能冒充导入证据。 +- evidence recorder 对 `importedProjectPath`、`logPath`、`screenshotPath`、项目 checksum 树和 `evidenceRoot` 输出目录做 workspace-relative、realpath 和 symlink fail-closed 检查,避免证据读写逃逸工作区。 + +## Controller 验证 + +- `pnpm --filter @huijing/api test -- devtool-import-evidence` PASS,26 files / 291 tests。存在既有 `pg@9` deprecation warning。 +- `pnpm --filter @huijing/api typecheck` PASS。 +- `pnpm --filter @huijing/api lint` PASS。 +- `node --check apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts` PASS。 +- `node --check apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts` PASS。 +- `node --check scripts/check-scope.mjs` PASS。 +- `pnpm check:s5-scope` PASS。 +- `pnpm --filter @huijing/shared-contracts test -- minigame-conversion` PASS,9 files / 71 tests。 +- `git diff --check` PASS;未跟踪文件额外 whitespace probe PASS。 +- `/tmp` scope probes PASS:Task5 dedicated path EXPECTED_PASS;generic path EXPECTED_FAIL;S6-S8 leak EXPECTED_FAIL。 +- `/tmp` symlink probes PASS:`logPath` symlink EXPECTED_FAIL;`evidenceRoot` symlink EXPECTED_FAIL_NO_WRITE。 + +## Fresh review + +- First spec review `019e96d1-851f-7d91-8b6a-da60168308a5`: PASS。 +- First quality review `019e96d1-85e5-7511-a925-f3143e9729ce`: FAIL,指出证据路径 symlink 可逃逸 workspace。 +- Fix implementers: + - `019e96d4-16a4-7a20-a0f3-bd892d6959bf`: 修 input path 和项目 checksum tree symlink fail-closed。 + - `019e96dd-5b91-7ba1-8146-375936972e9b`: 修 `evidenceRoot` 输出目录 symlink/realpath fail-closed。 +- Final spec re-review `019e96e6-7b08-7b23-9c10-532f8f404cf6`: PASS。 +- Final quality re-review `019e96e6-7e43-75b3-abcd-9b0051bac32c`: PASS。 + +## 下一步 + +S5 Task5 可以提交。S5 最终验收仍需要 Task6 跑完整 S5 verification,包括真实或工具链驱动的 `smoke:devtool-import` 产出 `DevToolImportEvidence.result="passed"`;如果只能产出 blocker/note,S5 仍是 No-Go。 diff --git a/scripts/check-scope.mjs b/scripts/check-scope.mjs index 24c514a9..b1489b90 100644 --- a/scripts/check-scope.mjs +++ b/scripts/check-scope.mjs @@ -257,6 +257,12 @@ const s5Task4ApprovedTerms = new Set([ "AssetManifest", "GamePackage" ]); +const s5Task5ApprovedTerms = new Set([ + ...s5Task4ApprovedTerms, + "DevToolImportEvidence", + "DevToolImportBlocker", + "ChannelReadinessNote" +]); const s5InheritedS2S3S4Terms = new Set([ ...s2ApprovedTerms, ...s3ApprovedTerms, @@ -281,6 +287,10 @@ const s5Task4ApprovedTermAllowPathPrefixes = [ ...s5Task3ApprovedTermAllowPathPrefixes, "apps/api/src/modules/static-gate/" ]; +const s5Task5ApprovedTermAllowExactPaths = new Set([ + "apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts", + "apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts" +]); const s5ApprovedTermReferenceAllowExactPaths = new Set([ ...s5ApprovedTermAllowExactPaths, // 共享合同根出口可以暴露 S5 Task1 合同,但不能因此把 index.ts 整体变成 S5-owned path。 @@ -6249,21 +6259,33 @@ function hasS5Task4StaticGateTermAllowPath(filePath) { return hasS5ContractTermAllowPath(filePath) || s5Task4ApprovedTermAllowPathPrefixes.some((prefix) => filePath.startsWith(prefix)); } +function hasS5Task5DevtoolEvidenceTermAllowPath(filePath) { + return hasS5ContractTermAllowPath(filePath) || s5Task5ApprovedTermAllowExactPaths.has(filePath); +} + function isAllowedS5ApprovedTermReference(filePath, term) { if (isAllowedS2ApprovedTermReference(filePath, term)) return true; if (isAllowedS3ApprovedTermReference(filePath, term)) return true; // S5 可以消费 S4 runtime/package 合同术语,但 /events/batch 仍属于 S6-S8 行为路径,不能继承 S4 mock/smoke 例外。 if (s5InheritedS2S3S4Terms.has(term) && term !== "/events/batch") { - return hasS4ApprovedTermAllowPath(filePath) || hasS5RuntimeAdapterTermAllowPath(filePath) || hasS5Task4StaticGateTermAllowPath(filePath); + return ( + hasS4ApprovedTermAllowPath(filePath) || + hasS5RuntimeAdapterTermAllowPath(filePath) || + hasS5Task4StaticGateTermAllowPath(filePath) || + hasS5Task5DevtoolEvidenceTermAllowPath(filePath) + ); } if (!s5ApprovedTerms.has(term)) return false; if (hasS5ContractTermAllowPath(filePath)) return true; // S5 Task4 允许 static gate 消费转换项目、报告和上游包/逻辑术语; - // Task5 devtool import 与 channel readiness 仍不放行,避免 static gate 冒充导入证据。 - return s5Task4ApprovedTerms.has(term) && hasS5Task4StaticGateTermAllowPath(filePath); + // Task5 只允许专用 devtool evidence recorder 消费导入证据术语,避免 converter/static gate 冒充导入证据。 + return ( + (s5Task4ApprovedTerms.has(term) && hasS5Task4StaticGateTermAllowPath(filePath)) || + (s5Task5ApprovedTerms.has(term) && hasS5Task5DevtoolEvidenceTermAllowPath(filePath)) + ); } function shouldSkipLaterPhaseTermViolation(filePath, term, phase) { @@ -11487,14 +11509,34 @@ export type ConversionReport = { evidenceLevel: "static_gate_passed"; }; await withFixture( { - "apps/api/src/modules/minigame-conversion/devtool-import.ts": ` + "apps/api/src/modules/minigame-conversion/devtool-import-evidence.ts": ` +export type DevToolImportEvidence = { result: "passed"; }; +export type DevToolImportBlocker = { result: "blocked"; }; +export type ChannelReadinessNote = { readiness: "future_channel_review_required"; }; +`, + "apps/api/src/modules/minigame-conversion/devtool-import-evidence.spec.ts": ` +import type { DevToolImportEvidence, DevToolImportBlocker, ChannelReadinessNote } from "./devtool-import-evidence.js"; +export const evidence: DevToolImportEvidence = { result: "passed" }; +export const blocker: DevToolImportBlocker = { result: "blocked" }; +export const note: ChannelReadinessNote = { readiness: "future_channel_review_required" }; +` + }, + async (root) => { + const violations = await findScopeViolations(root, "S5"); + assertNoViolations(violations, "S5 allows Task5 dedicated devtool import evidence recorder paths"); + } + ); + + await withFixture( + { + "apps/api/src/modules/minigame-conversion/index.ts": ` export type DevToolImportEvidence = { result: "passed"; }; export type DevToolImportBlocker = { result: "blocked"; }; ` }, async (root) => { const violations = await findScopeViolations(root, "S5"); - assertHasReason(violations, "LATER_PHASE_SCOPE_LEAK", "S5 Task3 converter path still rejects Task5 devtool import terms"); + assertHasReason(violations, "LATER_PHASE_SCOPE_LEAK", "S5 still rejects Task5 devtool import terms from generic converter paths"); } );