feat(s5): add final verification cli

This commit is contained in:
zizi 2026-06-05 17:25:33 +08:00
parent 5fd4f66217
commit 7874ca4122
7 changed files with 899 additions and 4 deletions

View File

@ -0,0 +1,48 @@
# S5 Task6 最终验证
## 结论
S5 Task6 已补齐可执行 final verification 入口:`pnpm run build:minigame``pnpm run smoke:devtool-import`。三端 simulation fixture 可以构建并通过 static gateDevTool import smoke 默认 fail-closed 写 blocker不会伪造 `DevToolImportEvidence.result="passed"`
当前 S5 final acceptance 仍是 No-Go本地没有真实开发者工具导入材料因此不存在可接受的 passed evidence 文件。只有提供真实 devtool 导入 log、tool name/version 和可选 screenshot 后,使用 `--allow-manual-passed-evidence` 才能生成 P0 可接受证据。
## 关键实现
- `package.json` 新增:
- `build:minigame`
- `smoke:devtool-import`
- `scripts/build-minigame.mjs` 复用 `MiniGameProjectBuilderService``StaticGateService`,生成 `simulation-fixture-v1` 三端项目并执行 static gate。
- `scripts/smoke-devtool-import.mjs` 默认写 `DevToolImportBlocker` 并 exit 1manual passed mode 必须显式传入 audited inputs并通过 `DevToolImportEvidenceRecorder` checksum/path 校验。
- `scripts/check-scope.mjs` 开始扫描 `scripts/`,只精确放行 Task6 四个 CLI/spec 文件,仍拒绝 generic scripts 与 S6-S8 泄漏。
- CLI tests 覆盖三端 build/static gate、默认 No-Go、manual passed probe、evidence root symlink 和 parent-chain symlink fail-closed。
## Controller 验证
- `node --check scripts/build-minigame.mjs` PASS。
- `node --check scripts/smoke-devtool-import.mjs` PASS。
- `node --check scripts/build-minigame.spec.mjs` PASS。
- `node --check scripts/smoke-devtool-import.spec.mjs` PASS。
- `node --test --test-concurrency=1 scripts/build-minigame.spec.mjs scripts/smoke-devtool-import.spec.mjs` PASS5 tests。
- `pnpm run build:minigame -- --target all --fixture simulation` PASSwechat/douyin/kuaishou static gate 均 `static_validated`kuaishou 保留 `KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN` warning。
- 默认 `pnpm run smoke:devtool-import -- --target wechat_minigame --game-version-id simulation-fixture-v1 --project dist/packages/simulation-fixture-v1/wechat_minigame --out docs/evidence/devtool-import/wechat_minigame-simulation-fixture-v1.json` EXPECTED No-Go PASSexit 1写 blocker未写 passed evidence。
- manual passed mode probe PASSprobe evidence/log/blocker 已清理,避免误当真实 DevTools 材料。
- `pnpm --filter @huijing/api test -- minigame-conversion static-gate devtool-import-evidence` PASS26 files / 291 tests。存在既有 `pg@9` deprecation warning。
- `pnpm --filter @huijing/api typecheck` PASS。
- `pnpm --filter @huijing/api lint` PASS。
- `pnpm check:s5-scope` PASS。
- `pnpm check:workspace-scripts` PASS。
- `pnpm lint` PASS。
- `pnpm typecheck` PASS。
- `pnpm test` PASS。
- `git diff --check` PASS未跟踪文件额外 whitespace probe PASS。
- 原始 no-overclaim grep 命中 shared-contracts 负向测试 fixture 中的 `device_smoke_verified`;排除测试文件后 PASS。
- Task6 evidence gate EXPECTED_NO_GO PASS`docs/evidence/devtool-import/wechat_minigame-simulation-fixture-v1.json` 不存在。
## Fresh review
- Spec review `019e9715-4004-78c1-a101-0441e5bd5315`: PASS。
- Quality review `019e9717-1c47-7fa3-81c0-54028d848552`: PASS。
## 下一步
S5 Task6 入口可提交,但 S5 阶段不能标记 final accepted直到真实开发者工具导入 smoke 产出 `DevToolImportEvidence.result="passed"`。如果没有真实材料,应保留 blocker/No-Go 并阻断 S6/S8。

View File

@ -6,8 +6,10 @@
"typecheck": "pnpm check:workspace-scripts && pnpm -r typecheck",
"test": "pnpm check:workspace-scripts && pnpm -r test",
"build": "pnpm check:workspace-scripts && pnpm -r build",
"build:minigame": "node scripts/build-minigame.mjs",
"dev": "pnpm -r --parallel --if-present dev",
"dev:smoke": "pnpm check:workspace-scripts && pnpm -r dev:smoke",
"smoke:devtool-import": "node scripts/smoke-devtool-import.mjs",
"check:workspace-scripts": "node scripts/check-workspace-scripts.mjs",
"check:s1-scope": "node scripts/check-scope.mjs --phase S1",
"check:s2-scope": "node scripts/check-scope.mjs --phase S2",

216
scripts/build-minigame.mjs Normal file
View File

@ -0,0 +1,216 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const workspaceRoot = process.cwd();
const validTargets = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"];
const simulationGameVersionId = "simulation-fixture-v1";
try {
const options = parseArgs(process.argv.slice(2));
await ensureApiBuild();
const { FileSystemMiniGameProjectRepository, MiniGameProjectBuilderService } = await importApiModule("minigame-conversion/index.js");
const { StaticGateService } = await importApiModule("static-gate/index.js");
const repository = new FileSystemMiniGameProjectRepository({ root: workspaceRoot });
const fixture = await createSimulationFixture(repository);
const targets = options.target === "all" ? validTargets : [options.target];
const builder = new MiniGameProjectBuilderService(repository);
const conversionResult = await builder.convertGamePackage({
gamePackage: fixture.gamePackage,
assetManifest: fixture.assetManifest,
targets
});
const staticGate = new StaticGateService({ root: workspaceRoot });
const targetSummaries = [];
for (const targetResult of conversionResult.targets) {
const gateResult = await staticGate.validateProject({
project: targetResult.project,
sourceLogic: {
path: fixture.gamePackage.profiles.web.manifest.logicArtifactPath,
content: fixture.logicSource
}
});
targetSummaries.push({
target: targetResult.project.target,
projectRoot: targetResult.project.rootPath,
conversionReportPath: targetResult.project.conversionReportPath,
assetManifestPath: targetResult.project.assetManifestPath,
projectChecksum: targetResult.project.checksum,
staticGate: gateResult
});
}
const failedTargets = targetSummaries.filter((summary) => summary.staticGate.status !== "static_validated");
if (failedTargets.length > 0) {
printJson({
status: "FAIL",
fixture: options.fixture,
gameVersionId: fixture.gamePackage.gameVersionId,
failedTargets
});
process.exitCode = 1;
} else {
printJson({
status: "PASS",
fixture: options.fixture,
gameVersionId: fixture.gamePackage.gameVersionId,
targets: targetSummaries
});
}
} catch (error) {
printJson(errorPayload("FAIL", error));
process.exitCode = 1;
}
function parseArgs(args) {
const values = new Map();
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--") continue;
if (!arg.startsWith("--")) throw new Error(`Unsupported positional argument: ${arg}`);
if (arg === "--target" || arg === "--fixture") {
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`);
values.set(arg.slice(2), value);
index += 1;
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
const target = values.get("target") ?? "all";
const fixture = values.get("fixture") ?? "simulation";
if (target !== "all" && !validTargets.includes(target)) {
throw new Error(`Unsupported --target: ${target}`);
}
if (fixture !== "simulation") {
throw new Error(`Unsupported --fixture: ${fixture}`);
}
return { target, fixture };
}
async function ensureApiBuild() {
// Node 当前只支持 strip-only TypeScript仓库模块里有参数属性等不可擦除语法先复用现有 tsc 输出再加载业务模块。
try {
await execFileAsync("pnpm", ["--filter", "@huijing/api", "exec", "tsc", "-p", "tsconfig.json"], {
cwd: workspaceRoot,
maxBuffer: 1024 * 1024 * 16
});
} catch (error) {
const detail = [error.stdout, error.stderr].filter(Boolean).join("\n");
throw new Error(`@huijing/api TypeScript build failed${detail ? `\n${detail}` : ""}`);
}
}
async function importApiModule(modulePath) {
return import(pathToFileURL(`${workspaceRoot}/apps/api/dist/modules/${modulePath}`).href);
}
async function createSimulationFixture(repository) {
const gameVersionId = simulationGameVersionId;
const packagePath = `dist/packages/${gameVersionId}/web`;
const logicSource = [
'import * as RuntimeSdk from "@huijing/runtime-sdk";',
"",
"export function init(sdk, config) {",
" void RuntimeSdk;",
" sdk.telemetry?.emit?.({ name: \"load_success\", gameVersionId: config.gameVersionId });",
" return { status: \"running\", gameVersionId: config.gameVersionId, tick: 0 };",
"}",
"export function update(state) { return { ...state, tick: state.tick + 1 }; }",
"export function render() { return [{ type: \"text\", value: \"simulation\" }]; }",
"export function handleInput(state) { return state; }",
"export function onPause() {}",
"export function onResume() {}",
""
].join("\n");
const assets = {
"assets/facility.png": "fake-png-bytes",
"assets/balance.json": "{\"score\":12}\n"
};
const assetManifest = {
id: `asset-manifest-${gameVersionId}`,
entries: [
{
path: "assets/facility.png",
hash: checksumText(assets["assets/facility.png"]),
sizeBytes: Buffer.byteLength(assets["assets/facility.png"]),
type: "image"
},
{
path: "assets/balance.json",
hash: checksumText(assets["assets/balance.json"]),
sizeBytes: Buffer.byteLength(assets["assets/balance.json"]),
type: "json"
}
]
};
const assetManifestText = stableJsonText(assetManifest);
const manifestPayload = {
entry: "runtime/index.js",
configPath: "config.json",
logicArtifactPath: "logic/logic.mjs",
assetManifestPath: "asset-manifest.json",
runtimeVersion: "web-runtime-mvp-v1"
};
const webManifest = {
...manifestPayload,
checksum: checksumText(JSON.stringify(manifestPayload))
};
const gamePackage = {
id: `game-package-${gameVersionId}`,
gameVersionId,
profiles: {
web: {
status: "built",
artifactPath: packagePath,
manifest: webManifest
},
wechat_minigame: { status: "pending_conversion" },
douyin_minigame: { status: "pending_conversion" },
kuaishou_minigame: { status: "pending_conversion" }
},
assetManifestId: assetManifest.id,
artifactChecksums: {
[assetManifest.id]: checksumText(assetManifestText),
[webManifest.logicArtifactPath]: checksumText(logicSource),
[webManifest.assetManifestPath]: checksumText(assetManifestText),
"manifest.json": webManifest.checksum
}
};
// 这里仅创建 S5 final gate 使用的 simulation web 包输入;真实转换和 static gate 仍由现有 API 模块完成。
await repository.replaceDirectory(packagePath);
await repository.writeTextFile(`${packagePath}/logic/logic.mjs`, logicSource);
await repository.writeTextFile(`${packagePath}/asset-manifest.json`, assetManifestText);
await repository.writeTextFile(`${packagePath}/manifest.json`, stableJsonText(webManifest));
await repository.writeTextFile(`${packagePath}/assets/facility.png`, assets["assets/facility.png"]);
await repository.writeTextFile(`${packagePath}/assets/balance.json`, assets["assets/balance.json"]);
return { gamePackage, assetManifest, logicSource };
}
function stableJsonText(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function checksumText(value) {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function printJson(value) {
process.stdout.write(stableJsonText(value));
}
function errorPayload(status, error) {
return {
status,
error: error instanceof Error ? error.message : String(error),
...(error && typeof error === "object" && "reasonCode" in error ? { reasonCode: error.reasonCode, path: error.path } : {})
};
}

View File

@ -0,0 +1,47 @@
import { execFile } from "node:child_process";
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
const execFileAsync = promisify(execFile);
const workspaceRoot = process.cwd();
const packageRoot = path.join(workspaceRoot, "dist/packages/simulation-fixture-v1");
afterEach(async () => {
await rm(path.join(workspaceRoot, "dist/packages/simulation-fixture-v1"), { recursive: true, force: true });
});
describe("build-minigame CLI", () => {
it("builds all simulation fixture mini-game targets and runs static gate", async () => {
const { stdout } = await execFileAsync("node", ["scripts/build-minigame.mjs", "--target", "all", "--fixture", "simulation"], {
cwd: workspaceRoot,
maxBuffer: 1024 * 1024 * 8
});
const output = JSON.parse(stdout);
assert.equal(output.status, "PASS");
assert.equal(output.fixture, "simulation");
assert.equal(output.gameVersionId, "simulation-fixture-v1");
assert.deepEqual(
output.targets.map((target) => target.target),
["wechat_minigame", "douyin_minigame", "kuaishou_minigame"]
);
assert.ok(output.targets.every((target) => target.staticGate.status === "static_validated"));
for (const target of ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"]) {
const targetRoot = path.join(packageRoot, target);
assert.equal(JSON.parse(await readFile(path.join(targetRoot, "conversion-report.json"), "utf8")).target, target);
assert.equal(JSON.parse(await readFile(path.join(targetRoot, "asset-manifest.json"), "utf8")).id, "asset-manifest-simulation-fixture-v1");
await readFile(path.join(targetRoot, "game.js"), "utf8");
await readFile(path.join(targetRoot, "game.json"), "utf8");
await readFile(path.join(targetRoot, "project.config.json"), "utf8");
await readFile(path.join(targetRoot, "js/platform-adapter.js"), "utf8");
await readFile(path.join(targetRoot, "js/runtime-sdk.js"), "utf8");
await readFile(path.join(targetRoot, "js/game-logic-module.js"), "utf8");
await readFile(path.join(targetRoot, "assets/facility.png"), "utf8");
await readFile(path.join(targetRoot, "assets/balance.json"), "utf8");
}
});
});

View File

@ -2,8 +2,12 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis
import os from "node:os";
import path from "node:path";
const scanRoots = ["apps", "packages"];
const scanRoots = ["apps", "packages", "scripts"];
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next", "generated"]);
const ignoredScanExactPaths = new Set([
// scope checker 内部必须包含全量 denylist 和自测 fixture真实阶段扫描只跳过 checker 自身,不跳过普通 scripts。
"scripts/check-scope.mjs"
]);
const sourceLikeFilePattern = /\.(?:ts|tsx|js|jsx|mjs|cjs|json|sql|prisma)$/;
const prismaMigrationPrefix = "apps/api/prisma/migrations/";
const generatedPrismaPrefix = "apps/api/src/generated/prisma/";
@ -291,6 +295,12 @@ 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 s5Task6CliAllowExactPaths = new Set([
"scripts/build-minigame.mjs",
"scripts/build-minigame.spec.mjs",
"scripts/smoke-devtool-import.mjs",
"scripts/smoke-devtool-import.spec.mjs"
]);
const s5ApprovedTermReferenceAllowExactPaths = new Set([
...s5ApprovedTermAllowExactPaths,
// 共享合同根出口可以暴露 S5 Task1 合同,但不能因此把 index.ts 整体变成 S5-owned path。
@ -338,7 +348,8 @@ function normalizePath(filePath) {
}
function isIgnoredScanPath(filePath) {
return filePath.split("/").some((segment) => ignoredScanSegments.has(segment));
const normalizedFilePath = normalizePath(filePath);
return ignoredScanExactPaths.has(normalizedFilePath) || normalizedFilePath.split("/").some((segment) => ignoredScanSegments.has(segment));
}
function isHarnessClientTestPath(filePath) {
@ -6263,6 +6274,10 @@ function hasS5Task5DevtoolEvidenceTermAllowPath(filePath) {
return hasS5ContractTermAllowPath(filePath) || s5Task5ApprovedTermAllowExactPaths.has(filePath);
}
function hasS5Task6CliTermAllowPath(filePath) {
return s5Task6CliAllowExactPaths.has(filePath);
}
function isAllowedS5ApprovedTermReference(filePath, term) {
if (isAllowedS2ApprovedTermReference(filePath, term)) return true;
if (isAllowedS3ApprovedTermReference(filePath, term)) return true;
@ -6273,7 +6288,8 @@ function isAllowedS5ApprovedTermReference(filePath, term) {
hasS4ApprovedTermAllowPath(filePath) ||
hasS5RuntimeAdapterTermAllowPath(filePath) ||
hasS5Task4StaticGateTermAllowPath(filePath) ||
hasS5Task5DevtoolEvidenceTermAllowPath(filePath)
hasS5Task5DevtoolEvidenceTermAllowPath(filePath) ||
hasS5Task6CliTermAllowPath(filePath)
);
}
@ -6284,7 +6300,8 @@ function isAllowedS5ApprovedTermReference(filePath, term) {
// Task5 只允许专用 devtool evidence recorder 消费导入证据术语,避免 converter/static gate 冒充导入证据。
return (
(s5Task4ApprovedTerms.has(term) && hasS5Task4StaticGateTermAllowPath(filePath)) ||
(s5Task5ApprovedTerms.has(term) && hasS5Task5DevtoolEvidenceTermAllowPath(filePath))
(s5Task5ApprovedTerms.has(term) && hasS5Task5DevtoolEvidenceTermAllowPath(filePath)) ||
(s5Task5ApprovedTerms.has(term) && hasS5Task6CliTermAllowPath(filePath))
);
}
@ -11540,6 +11557,46 @@ export type DevToolImportBlocker = { result: "blocked"; };
}
);
await withFixture(
{
"scripts/build-minigame.mjs": `
const modulePath = "minigame-conversion/index.js";
const runtimePackage = "runtime-sdk";
const webRuntimeVersion = "web-runtime-mvp-v1";
const gamePackageId = "game-package-simulation-fixture-v1";
export const summary = { modulePath, runtimePackage, webRuntimeVersion, gamePackageId };
`,
"scripts/smoke-devtool-import.mjs": `
const modulePath = "minigame-conversion/devtool-import-evidence.js";
export class DevToolImportEvidenceRecorder {}
export const result = { modulePath, status: "DevToolImportBlocker" };
`,
"scripts/build-minigame.spec.mjs": `
export const runtimePath = "js/runtime-sdk.js";
`,
"scripts/smoke-devtool-import.spec.mjs": `
export const passedShape = "DevToolImportEvidence";
`
},
async (root) => {
const violations = await findScopeViolations(root, "S5");
assertNoViolations(violations, "S5 Task6 exact CLI scripts can consume S5 verification terms");
}
);
await withFixture(
{
"scripts/generic-minigame-helper.mjs": `
export class DevToolImportEvidenceRecorder {}
export const modulePath = "minigame-conversion/devtool-import-evidence.js";
`
},
async (root) => {
const violations = await findScopeViolations(root, "S5");
assertHasReason(violations, "LATER_PHASE_SCOPE_LEAK", "S5 still rejects Task6 terms from generic scripts");
}
);
await withFixture(
{
"packages/shared-contracts/src/minigame-conversion.ts": `

View File

@ -0,0 +1,297 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { lstat, mkdir, realpath, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const workspaceRoot = process.cwd();
const validTargets = ["wechat_minigame", "douyin_minigame", "kuaishou_minigame"];
const defaultEvidenceRoot = "docs/evidence/devtool-import";
const pathSymlinkUnsupportedReason = "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED";
const pathRealpathEscapeReason = "DEVTOOL_IMPORT_PATH_REALPATH_ESCAPE";
try {
await main();
} catch (error) {
printJson(errorPayload("FAIL", error));
process.exitCode = 1;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
validateOutPath(options);
await ensureApiBuild();
const { DevToolImportEvidenceRecorder, selectReferencePlatform } = await importApiModule("minigame-conversion/devtool-import-evidence.js");
const recorder = new DevToolImportEvidenceRecorder({ workspaceRoot, evidenceRoot: defaultEvidenceRoot });
const selection =
options.target === "wechat_minigame" ? selectReferencePlatform({}) : selectReferencePlatform({ businessPrimaryTarget: options.target });
if (!options.allowManualPassedEvidence) {
const blockerLogPath = await writeDefaultBlockerLog(options);
const blocker = await recorder.recordBlocker({
target: options.target,
gameVersionId: options.gameVersionId,
result: "blocked",
reasonCode: "DEVTOOL_UNAVAILABLE",
logPath: blockerLogPath,
nextAction: "Run the target developer tool import manually, keep the real import log, then rerun with --allow-manual-passed-evidence, --tool-name, --tool-version and --log.",
createdAt: isoTimestamp()
});
const blockerPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.blocker.json`;
printJson({
status: "NO_GO",
target: blocker.target,
gameVersionId: blocker.gameVersionId,
result: blocker.result,
reasonCode: blocker.reasonCode,
blockerPath,
nextAction: blocker.nextAction
});
process.exitCode = 1;
return;
}
if (!options.toolName || !options.toolVersion || !options.log) {
throw new Error("--allow-manual-passed-evidence requires --tool-name, --tool-version and --log");
}
const projectChecksum = await recorder.checksumProjectDirectory(options.project);
const evidence = await recorder.recordPassedEvidence({
target: options.target,
gameVersionId: options.gameVersionId,
referencePlatformReason: selection.referencePlatformReason,
toolName: options.toolName,
toolVersion: options.toolVersion,
importedProjectPath: options.project,
projectChecksum,
logPath: options.log,
...(options.screenshot === undefined ? {} : { screenshotPath: options.screenshot }),
createdAt: isoTimestamp()
});
const defaultPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.json`;
if (options.out !== defaultPath) {
await moveDefaultEvidenceToOut(defaultPath, options.out);
}
printJson({
status: "PASS",
target: evidence.target,
gameVersionId: evidence.gameVersionId,
result: evidence.result,
evidencePath: options.out,
projectChecksum: evidence.projectChecksum
});
}
function parseArgs(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--") continue;
if (arg === "--allow-manual-passed-evidence") {
options.allowManualPassedEvidence = true;
continue;
}
const valueArgs = ["--target", "--game-version-id", "--project", "--out", "--tool-name", "--tool-version", "--log", "--screenshot"];
if (valueArgs.includes(arg)) {
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`);
options[toCamelCase(arg.slice(2))] = value;
index += 1;
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
const required = ["target", "gameVersionId", "project", "out"];
for (const field of required) {
if (!options[field]) throw new Error(`Missing required --${toKebabCase(field)}`);
}
if (!validTargets.includes(options.target)) throw new Error(`Unsupported --target: ${options.target}`);
assertSafeFileSegment(options.gameVersionId, "game-version-id");
for (const field of ["project", "out", "log", "screenshot"]) {
if (options[field] !== undefined) assertControlledRelativePath(options[field], field);
}
return options;
}
function validateOutPath(options) {
const expectedName = `${options.target}-${options.gameVersionId}.json`;
const expectedPath = `${defaultEvidenceRoot}/${expectedName}`;
if (options.out !== expectedPath) {
// 当前 recorder 只按 target/gameVersionId 写默认命名CLI 精确约束 out避免证据输出路径被参数扩展。
throw new Error(`--out must be ${expectedPath}`);
}
}
async function ensureApiBuild() {
try {
await execFileAsync("pnpm", ["--filter", "@huijing/api", "exec", "tsc", "-p", "tsconfig.json"], {
cwd: workspaceRoot,
maxBuffer: 1024 * 1024 * 16
});
} catch (error) {
const detail = [error.stdout, error.stderr].filter(Boolean).join("\n");
throw new Error(`@huijing/api TypeScript build failed${detail ? `\n${detail}` : ""}`);
}
}
async function importApiModule(modulePath) {
return import(pathToFileURL(`${workspaceRoot}/apps/api/dist/modules/${modulePath}`).href);
}
async function writeDefaultBlockerLog(options) {
const logPath = `${defaultEvidenceRoot}/${options.target}-${options.gameVersionId}.blocked.log`;
const absoluteEvidenceRoot = await prepareSafeWorkspaceOutputRoot(defaultEvidenceRoot, "evidenceRoot");
const absoluteLogPath = path.resolve(workspaceRoot, logPath);
if (!isInsideOrSame(absoluteEvidenceRoot, absoluteLogPath)) throw new Error("blocker log path escaped evidence root");
// 默认路径只记录 No-Go 原因,不记录 passed 结果;开发者工具真实导入材料必须由显式 manual passed mode 提供。
await writeFile(
absoluteLogPath,
[
"S5 No-Go: developer tool import was not executed by this CLI.",
`target=${options.target}`,
`gameVersionId=${options.gameVersionId}`,
`project=${options.project}`,
"nextAction=rerun with audited manual import log and --allow-manual-passed-evidence",
""
].join("\n"),
"utf8"
);
return logPath;
}
async function prepareSafeWorkspaceOutputRoot(repositoryPath, fieldPath) {
const resolvedRoot = resolveWorkspacePath(repositoryPath, "DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", fieldPath);
// 创建输出目录前后都检查已存在父链;否则 mkdir/writeFile 会跟随 workspace 内 symlink 写到工作区外。
await assertSafeExistingPathSegments(resolvedRoot, repositoryPath, fieldPath);
await mkdir(resolvedRoot, { recursive: true });
await assertSafeExistingPathSegments(resolvedRoot, repositoryPath, fieldPath);
const stats = await lstat(resolvedRoot);
if (!stats.isDirectory()) {
throw pathBoundaryError("DEVTOOL_IMPORT_EVIDENCE_PATH_INVALID", "Devtool import evidence root is not a directory", fieldPath, {
repositoryPath
});
}
return resolvedRoot;
}
async function assertSafeExistingPathSegments(resolvedPath, repositoryPath, fieldPath) {
const workspaceRootReal = await realpath(workspaceRoot);
const relativePath = path.relative(workspaceRoot, resolvedPath);
let currentPath = workspaceRoot;
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 pathBoundaryError(pathSymlinkUnsupportedReason, "Devtool import evidence paths do not support symlinks", fieldPath, {
repositoryPath
});
}
const currentReal = await realpath(currentPath);
if (!isInsideOrSame(workspaceRootReal, currentReal)) {
throw pathBoundaryError(pathRealpathEscapeReason, "Devtool import evidence real path escaped workspace root", fieldPath, {
repositoryPath,
realPath: currentReal
});
}
}
}
function resolveWorkspacePath(repositoryPath, reasonCode, fieldPath) {
assertControlledRelativePath(repositoryPath, fieldPath);
const resolved = path.resolve(workspaceRoot, repositoryPath);
if (!isInsideOrSame(workspaceRoot, resolved)) {
throw pathBoundaryError(reasonCode, "Path escaped workspace root", fieldPath, { repositoryPath });
}
return resolved;
}
function pathBoundaryError(reasonCode, message, fieldPath, details = {}) {
return Object.assign(new Error(message), {
reasonCode,
path: fieldPath,
details
});
}
async function moveDefaultEvidenceToOut(defaultPath, outPath) {
const absoluteDefaultPath = path.resolve(workspaceRoot, defaultPath);
const absoluteOutPath = path.resolve(workspaceRoot, outPath);
if (!isInsideOrSame(workspaceRoot, absoluteDefaultPath) || !isInsideOrSame(workspaceRoot, absoluteOutPath)) {
throw new Error("evidence output path escaped workspace root");
}
await mkdir(path.dirname(absoluteOutPath), { recursive: true });
await rename(absoluteDefaultPath, absoluteOutPath);
}
function toCamelCase(value) {
return value.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
}
function toKebabCase(value) {
return value.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
}
function assertSafeFileSegment(value, fieldPath) {
if (!/^[A-Za-z0-9._-]+$/.test(value) || value === "." || value === "..") {
throw new Error(`Unsafe --${fieldPath}: ${value}`);
}
}
function assertControlledRelativePath(value, fieldPath) {
if (!isControlledRelativePath(value)) throw new Error(`Unsafe --${toKebabCase(fieldPath)} path: ${value}`);
}
function isControlledRelativePath(value) {
if (value.length === 0 || value.startsWith("/") || value.startsWith("\\") || value.includes("\\") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) {
return false;
}
let decodedValue;
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 isInsideOrSame(rootPath, candidatePath) {
return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${path.sep}`);
}
function isoTimestamp() {
return "2026-06-05T00:00:00.000Z";
}
function stableJsonText(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function printJson(value) {
process.stdout.write(stableJsonText(value));
}
function errorPayload(status, error) {
return {
status,
error: error instanceof Error ? error.message : String(error),
...(error && typeof error === "object" && "reasonCode" in error ? { reasonCode: error.reasonCode, path: error.path } : {})
};
}

View File

@ -0,0 +1,228 @@
import { execFile } from "node:child_process";
import { lstat, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, it } from "node:test";
import assert from "node:assert/strict";
const execFileAsync = promisify(execFile);
const workspaceRoot = process.cwd();
const evidenceRoot = path.join(workspaceRoot, "docs/evidence/devtool-import");
const gameVersionId = "simulation-fixture-v1";
const target = "wechat_minigame";
const projectPath = `dist/packages/${gameVersionId}/${target}`;
const outPath = `docs/evidence/devtool-import/${target}-${gameVersionId}.json`;
const blockerPath = path.join(evidenceRoot, `${target}-${gameVersionId}.blocker.json`);
const evidencePath = path.join(workspaceRoot, outPath);
const logPath = `docs/evidence/devtool-import/${target}-${gameVersionId}.manual.log`;
const blockerLogPath = path.join(evidenceRoot, `${target}-${gameVersionId}.blocked.log`);
const outsideRoots = [];
const swappedPaths = [];
beforeEach(async () => {
await rm(path.join(workspaceRoot, "dist/packages/simulation-fixture-v1"), { recursive: true, force: true });
await restoreSwappedPaths();
await cleanGeneratedEvidenceFiles();
await execFileAsync("node", ["scripts/build-minigame.mjs", "--target", "all", "--fixture", "simulation"], {
cwd: workspaceRoot,
maxBuffer: 1024 * 1024 * 8
});
});
afterEach(async () => {
await rm(path.join(workspaceRoot, "dist/packages/simulation-fixture-v1"), { recursive: true, force: true });
await restoreSwappedPaths();
await cleanGeneratedEvidenceFiles();
for (const outsideRoot of outsideRoots.splice(0)) {
await rm(outsideRoot, { recursive: true, force: true });
}
});
describe("smoke-devtool-import CLI", () => {
it("fails closed and writes a blocker when manual passed evidence is not explicitly allowed", async () => {
await assert.rejects(
execFileAsync(
"node",
[
"scripts/smoke-devtool-import.mjs",
"--target",
target,
"--game-version-id",
gameVersionId,
"--project",
projectPath,
"--out",
outPath
],
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
),
(error) => {
assert.equal(error.code, 1);
const output = JSON.parse(error.stdout);
assert.equal(output.status, "NO_GO");
assert.equal(output.result, "blocked");
assert.equal(output.blockerPath, `docs/evidence/devtool-import/${target}-${gameVersionId}.blocker.json`);
return true;
}
);
const blocker = JSON.parse(await readFile(blockerPath, "utf8"));
assert.equal(blocker.result, "blocked");
assert.equal(blocker.reasonCode, "DEVTOOL_UNAVAILABLE");
});
it("fails closed before writing default blocker log when evidence root is a symlink", async () => {
const outsideRoot = await mkdtemp(path.join(tmpdir(), "huijing-s5-smoke-devtool-outside-"));
outsideRoots.push(outsideRoot);
await swapPathForSymlink(evidenceRoot, outsideRoot);
await assert.rejects(
execFileAsync(
"node",
[
"scripts/smoke-devtool-import.mjs",
"--target",
target,
"--game-version-id",
gameVersionId,
"--project",
projectPath,
"--out",
outPath
],
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
),
(error) => {
assert.equal(error.code, 1);
const output = JSON.parse(error.stdout);
assert.equal(output.status, "FAIL");
assert.equal(output.reasonCode, "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED");
assert.equal(output.path, "evidenceRoot");
return true;
}
);
await assert.rejects(readFile(path.join(outsideRoot, `${target}-${gameVersionId}.blocked.log`), "utf8"), { code: "ENOENT" });
await assert.rejects(readFile(path.join(outsideRoot, `${target}-${gameVersionId}.blocker.json`), "utf8"), { code: "ENOENT" });
});
it("fails closed before writing default blocker log when evidence root parent is a symlink", async () => {
const outsideRoot = await mkdtemp(path.join(tmpdir(), "huijing-s5-smoke-devtool-parent-outside-"));
outsideRoots.push(outsideRoot);
const symlinkParent = path.join(workspaceRoot, "docs/evidence");
await swapPathForSymlink(symlinkParent, outsideRoot);
await assert.rejects(
execFileAsync(
"node",
[
"scripts/smoke-devtool-import.mjs",
"--target",
target,
"--game-version-id",
gameVersionId,
"--project",
projectPath,
"--out",
outPath
],
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
),
(error) => {
assert.equal(error.code, 1);
const output = JSON.parse(error.stdout);
assert.equal(output.status, "FAIL");
assert.equal(output.reasonCode, "DEVTOOL_IMPORT_PATH_SYMLINK_UNSUPPORTED");
assert.equal(output.path, "evidenceRoot");
return true;
}
);
await assert.rejects(readFile(path.join(outsideRoot, `devtool-import/${target}-${gameVersionId}.blocked.log`), "utf8"), { code: "ENOENT" });
await assert.rejects(readFile(path.join(outsideRoot, `devtool-import/${target}-${gameVersionId}.blocker.json`), "utf8"), { code: "ENOENT" });
});
it("records passed evidence only with explicit audited manual evidence inputs", async () => {
await mkdir(evidenceRoot, { recursive: true });
await writeFile(path.join(workspaceRoot, logPath), "manual devtool import passed\n", "utf8");
const { stdout } = await execFileAsync(
"node",
[
"scripts/smoke-devtool-import.mjs",
"--target",
target,
"--game-version-id",
gameVersionId,
"--project",
projectPath,
"--out",
outPath,
"--allow-manual-passed-evidence",
"--tool-name",
"WeChat DevTools",
"--tool-version",
"1.06.2504010",
"--log",
logPath
],
{ cwd: workspaceRoot, maxBuffer: 1024 * 1024 * 8 }
);
const output = JSON.parse(stdout);
assert.equal(output.status, "PASS");
assert.equal(output.result, "passed");
assert.equal(output.evidencePath, outPath);
const evidence = JSON.parse(await readFile(evidencePath, "utf8"));
assert.equal(evidence.result, "passed");
assert.equal(evidence.target, target);
assert.equal(evidence.toolName, "WeChat DevTools");
assert.equal(evidence.importedProjectPath, projectPath);
});
});
async function cleanGeneratedEvidenceFiles() {
await rm(blockerPath, { force: true });
await rm(blockerLogPath, { force: true });
await rm(evidencePath, { force: true });
await rm(path.join(workspaceRoot, logPath), { force: true });
}
async function swapPathForSymlink(targetPath, outsideRoot) {
const backupPath = await mkdtemp(path.join(tmpdir(), "huijing-s5-smoke-devtool-backup-"));
await rm(backupPath, { recursive: true, force: true });
await mkdir(path.dirname(targetPath), { recursive: true });
let hasBackup = true;
try {
await rename(targetPath, backupPath);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
hasBackup = false;
}
await symlink(outsideRoot, targetPath);
swappedPaths.push({ targetPath, backupPath, hasBackup });
}
async function restoreSwappedPaths() {
for (const swap of swappedPaths.splice(0).reverse()) {
try {
const stats = await lstat(swap.targetPath);
if (stats.isSymbolicLink()) {
await rm(swap.targetPath, { force: true });
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
if (swap.hasBackup) {
await mkdir(path.dirname(swap.targetPath), { recursive: true });
await rename(swap.backupPath, swap.targetPath);
} else {
await rm(swap.backupPath, { recursive: true, force: true });
}
}
}