feat(s5): add devtool import evidence
This commit is contained in:
parent
1acc959180
commit
5fd4f66217
@ -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;
|
||||
}
|
||||
@ -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")}`;
|
||||
}
|
||||
19
docs/evidence/devtool-import/README.md
Normal file
19
docs/evidence/devtool-import/README.md
Normal 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.
|
||||
40
docs/memorys/2026-06-05-S5Task5导入证据.md
Normal file
40
docs/memorys/2026-06-05-S5Task5导入证据.md
Normal 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` 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。
|
||||
@ -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");
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user