feat(s5): add devtool import evidence

This commit is contained in:
zizi 2026-06-05 16:33:41 +08:00
parent 1acc959180
commit 5fd4f66217
5 changed files with 1219 additions and 5 deletions

View File

@ -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<EvidenceContext> {
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<string> {
const root = await mkdtemp(path.join(os.tmpdir(), prefix));
tempRoots.push(root);
return root;
}

View File

@ -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<string, unknown>;
constructor(reasonCode: string, message: string, details: Record<string, unknown> = {}) {
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<string>;
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<string> {
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<DevToolImportEvidence> {
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<DevToolImportBlocker> {
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<string> {
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<void> {
// 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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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<readonly FileChecksum[]> {
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<void> {
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<void> {
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<string, unknown>,
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<string, unknown>,
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<DevToolImportValidationResult, { ok: false }>, 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<string, unknown> {
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<T extends string>(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")}`;
}

View File

@ -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.

View File

@ -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` PASS26 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` PASS9 files / 71 tests。
- `git diff --check` PASS未跟踪文件额外 whitespace probe PASS。
- `/tmp` scope probes PASSTask5 dedicated path EXPECTED_PASSgeneric path EXPECTED_FAILS6-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/noteS5 仍是 No-Go。

View File

@ -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");
}
);