opencode-workflow-guard 1.15.0 → 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/policies.md +2 -2
- package/package.json +1 -1
- package/src/lib/custom-tools.ts +58 -18
- package/src/lib/guard-dispatcher.ts +31 -3
- package/src/lib/state.ts +118 -49
- package/src/lib/v2-plugin.ts +10 -10
- package/src/policies/boundary.ts +9 -2
- package/src/policies/changelog.ts +112 -11
- package/src/policies/git.ts +134 -11
- package/src/workflow-guard-ui.ts +1 -2
- package/src/workflow-guard.ts +14 -8
- package/test/test.mts +254 -9
package/test/test.mts
CHANGED
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
persistReviewCache,
|
|
46
46
|
setSessionWorkspace,
|
|
47
47
|
getSessionWorkspace,
|
|
48
|
+
getSessionBoundWorkspace,
|
|
49
|
+
registerSessionWorkspaceFromToolCall,
|
|
48
50
|
resolveEffectiveWorkspace,
|
|
49
51
|
getLastReviewResultForWorkspace,
|
|
50
52
|
isSameGitRepo,
|
|
@@ -1600,22 +1602,19 @@ const fakeV2TuiCtx = {
|
|
|
1600
1602
|
ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
|
|
1601
1603
|
};
|
|
1602
1604
|
await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
|
|
1603
|
-
check("v2 tui companion registers
|
|
1605
|
+
check("v2 tui companion registers only the prompt footer status slot", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 1 && v2SlotClaims.some((claim) => claim.append === "prompt.footer.status"));
|
|
1604
1606
|
const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
|
|
1605
1607
|
const diagLogText = () => readFileSync(diagLogPath, "utf8");
|
|
1606
1608
|
check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
|
|
1607
|
-
const
|
|
1609
|
+
const v2StatusRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1608
1610
|
// In a plain Node process there is no OpenTUI renderer, so badge() throws the
|
|
1609
1611
|
// same "No renderer found" the long-running-session bug produced in the TUI.
|
|
1610
|
-
check("v2 slot render returns null instead of throwing when the render context is unavailable",
|
|
1611
|
-
check("v2 slot failure log records probes and stack", diagLogText().includes("render
|
|
1612
|
-
for (let i = 0; i < 6; i++)
|
|
1613
|
-
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render
|
|
1612
|
+
check("v2 slot render returns null instead of throwing when the render context is unavailable", v2StatusRender() === null);
|
|
1613
|
+
check("v2 slot failure log records probes and stack", diagLogText().includes("render prompt.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
|
|
1614
|
+
for (let i = 0; i < 6; i++) v2StatusRender();
|
|
1615
|
+
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render prompt.footer.status FAILED (")).length;
|
|
1614
1616
|
check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
|
|
1615
1617
|
check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
|
|
1616
|
-
const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1617
|
-
check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
|
|
1618
|
-
|
|
1619
1618
|
|
|
1620
1619
|
// ── Adversarial tests & hardened invariants ──
|
|
1621
1620
|
console.log("- Adversarial tests & hardened invariants -");
|
|
@@ -3123,6 +3122,39 @@ spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conf
|
|
|
3123
3122
|
const baseUpToDateCheck = checkBranchBaseIsUpToDate(conflictRepo);
|
|
3124
3123
|
check("checkBranchBaseIsUpToDate passes when equal to remote", !baseUpToDateCheck.isBehind);
|
|
3125
3124
|
|
|
3125
|
+
// Policy 20: branch creation start-point awareness. Advance the simulated
|
|
3126
|
+
// remote past the current branch so HEAD is stale, then verify that an
|
|
3127
|
+
// explicit fresh start point clears the gate while startless and
|
|
3128
|
+
// indeterminate creations still fail (closed).
|
|
3129
|
+
spawnSync("git", ["switch", "-c", "wg-origin-advance"], { cwd: conflictRepo });
|
|
3130
|
+
writeFileSync(join(conflictRepo, "file.txt"), "remote advance content\n");
|
|
3131
|
+
spawnSync("git", ["commit", "-am", "remote advance"], { cwd: conflictRepo });
|
|
3132
|
+
spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conflictRepo });
|
|
3133
|
+
spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
|
|
3134
|
+
const staleHeadCheck = checkBranchBaseIsUpToDate(conflictRepo);
|
|
3135
|
+
check(
|
|
3136
|
+
"checkBranchBaseIsUpToDate attributes the stale current branch and working remedies",
|
|
3137
|
+
staleHeadCheck.isBehind &&
|
|
3138
|
+
staleHeadCheck.baseRef === "origin/main" &&
|
|
3139
|
+
(staleHeadCheck.reason ?? "").includes("'feat/conflict-branch'") &&
|
|
3140
|
+
(staleHeadCheck.reason ?? "").includes("git rebase origin/main"),
|
|
3141
|
+
);
|
|
3142
|
+
const freshStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main"]);
|
|
3143
|
+
check("checkBranchBaseIsUpToDate accepts an explicit start point containing the remote default", !freshStartCheck.isBehind);
|
|
3144
|
+
const staleStartSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: conflictRepo, encoding: "utf8" }).stdout!.trim();
|
|
3145
|
+
const staleStartCheck = checkBranchBaseIsUpToDate(conflictRepo, [staleStartSha]);
|
|
3146
|
+
check("checkBranchBaseIsUpToDate blocks an explicit stale start point", staleStartCheck.isBehind && (staleStartCheck.reason ?? "").includes("start point"));
|
|
3147
|
+
const unresolvableCheck = checkBranchBaseIsUpToDate(conflictRepo, ["$WG_START_REF"]);
|
|
3148
|
+
check("checkBranchBaseIsUpToDate fails closed on an unresolvable start point", unresolvableCheck.isBehind && (unresolvableCheck.reason ?? "").includes("could not be resolved"));
|
|
3149
|
+
const mixedStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main", undefined]);
|
|
3150
|
+
check("checkBranchBaseIsUpToDate keeps the head check when a creation lacks a start point", mixedStartCheck.isBehind && (mixedStartCheck.reason ?? "").includes("'feat/conflict-branch'"));
|
|
3151
|
+
check("git switch -c without a start point is blocked on a stale branch", blocked(await shell("git switch -c wg-start-stale")));
|
|
3152
|
+
check("git switch -c origin/main with a stale branch is allowed", !blocked(await shell("git switch -c wg-start-fresh origin/main")));
|
|
3153
|
+
check("git checkout -b origin/main with a stale branch is allowed", !blocked(await shell("git checkout -b wg-checkout-fresh origin/main")));
|
|
3154
|
+
check("wrapper git switch -c origin/main keeps the fresh start point exemption", !blocked(await shell("env git switch -c wg-wrapper-fresh origin/main")));
|
|
3155
|
+
check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
|
|
3156
|
+
check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
|
|
3157
|
+
|
|
3126
3158
|
// Lockfile synchronization tests
|
|
3127
3159
|
const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
|
|
3128
3160
|
spawnSync("git", ["init", "-b", "main"], { cwd: lockRepo });
|
|
@@ -3139,6 +3171,219 @@ writeFileSync(join(lockRepo, "package-lock.json"), "{\"name\":\"demo\",\"version
|
|
|
3139
3171
|
check("checkLockfileSync passes when lockfile is updated", !checkLockfileSync(lockRepo).isOutOfSync);
|
|
3140
3172
|
rmSync(lockRepo, { recursive: true, force: true });
|
|
3141
3173
|
|
|
3174
|
+
// Lockfile synchronization tracks lockfile-relevant manifest sections, not
|
|
3175
|
+
// the mere presence of package.json in the branch diff. A scripts-only edit
|
|
3176
|
+
// (the documented curation act of adding a suite to a test script) regenerates
|
|
3177
|
+
// package-lock.json byte-identical, so demanding a lockfile change for it is
|
|
3178
|
+
// unsatisfiable; dependency/engines/packageManager changes still demand one.
|
|
3179
|
+
const scriptLockRepo = mkdtempSync(join(tmpdir(), "wg-lock-scripts-"));
|
|
3180
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: scriptLockRepo });
|
|
3181
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: scriptLockRepo });
|
|
3182
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: scriptLockRepo });
|
|
3183
|
+
const scriptManifestBase = { name: "demo", version: "1.0.0", scripts: { test: "node test.mjs" } };
|
|
3184
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestBase, null, 2) + "\n");
|
|
3185
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3 }, null, 2) + "\n");
|
|
3186
|
+
// A nested manifest already present at the merge base, so its scripts-only
|
|
3187
|
+
// edit on the branch is a true edit (not a new manifest).
|
|
3188
|
+
mkdirSync(join(scriptLockRepo, "packages", "app"), { recursive: true });
|
|
3189
|
+
const nestedManifestBase = { name: "app", version: "0.0.0", scripts: { build: "node build.mjs" } };
|
|
3190
|
+
writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestBase, null, 2) + "\n");
|
|
3191
|
+
spawnSync("git", ["add", "-A"], { cwd: scriptLockRepo });
|
|
3192
|
+
spawnSync("git", ["commit", "-qm", "init"], { cwd: scriptLockRepo });
|
|
3193
|
+
spawnSync("git", ["switch", "-c", "feat/scripts-only"], { cwd: scriptLockRepo });
|
|
3194
|
+
const scriptManifestEdited = JSON.parse(JSON.stringify(scriptManifestBase)) as typeof scriptManifestBase;
|
|
3195
|
+
scriptManifestEdited.scripts.test = "node test.mjs && node test-ci.mjs";
|
|
3196
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestEdited, null, 2) + "\n");
|
|
3197
|
+
check("checkLockfileSync passes an uncommitted scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3198
|
+
spawnSync("git", ["commit", "-qam", "curate test suite"], { cwd: scriptLockRepo });
|
|
3199
|
+
check("checkLockfileSync passes a committed scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3200
|
+
// The observed false positive: a scripts-only edit alongside a lockfile that
|
|
3201
|
+
// npm rewrote byte-identical (present in the diff set, yet unchanged in
|
|
3202
|
+
// content — the old check demanded exactly this unsatisfiable combination).
|
|
3203
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), readFileSync(join(scriptLockRepo, "package-lock.json"), "utf8"));
|
|
3204
|
+
check("checkLockfileSync passes a byte-identical lockfile rewrite next to a scripts-only edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3205
|
+
// A nested manifest's scripts-only edit passes too.
|
|
3206
|
+
const nestedManifestEdited = JSON.parse(JSON.stringify(nestedManifestBase)) as typeof nestedManifestBase;
|
|
3207
|
+
nestedManifestEdited.scripts.build = "node build.mjs && node verify.mjs";
|
|
3208
|
+
writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestEdited, null, 2) + "\n");
|
|
3209
|
+
check("checkLockfileSync passes a scripts-only nested manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3210
|
+
// Contract pins: sections the lockfile records still demand the lockfile.
|
|
3211
|
+
const scriptsAfterNesting = scriptManifestEdited.scripts;
|
|
3212
|
+
const relevantManifest = { name: "demo", version: "1.0.0", scripts: scriptsAfterNesting };
|
|
3213
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3214
|
+
check("checkLockfileSync flags a dependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3215
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, engines: { node: ">=22" } }, null, 2) + "\n");
|
|
3216
|
+
check("checkLockfileSync flags an engines change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3217
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, packageManager: "pnpm@9.0.0" }, null, 2) + "\n");
|
|
3218
|
+
check("checkLockfileSync flags a packageManager change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3219
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, devDependencies: { typescript: "^5.0.0" } }, null, 2) + "\n");
|
|
3220
|
+
check("checkLockfileSync flags a devDependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3221
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, pnpm: { overrides: { "left-pad": "^1.3.0" } } }, null, 2) + "\n");
|
|
3222
|
+
check("checkLockfileSync flags a pnpm-overrides change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3223
|
+
// And a relevant change WITH the lockfile updated passes.
|
|
3224
|
+
writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3225
|
+
writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
|
|
3226
|
+
check("checkLockfileSync passes when the lockfile is updated alongside a relevant change", !checkLockfileSync(scriptLockRepo).isOutOfSync);
|
|
3227
|
+
rmSync(scriptLockRepo, { recursive: true, force: true });
|
|
3228
|
+
|
|
3229
|
+
// Concurrent loops share ONE guard process: workspace resolution must come
|
|
3230
|
+
// from the call's own context or the session's own binding, never from the
|
|
3231
|
+
// last-active plugin instance. Two plugin instances in this process simulate
|
|
3232
|
+
// the two loops; instance B's roots must never re-anchor session A's context.
|
|
3233
|
+
const isoRepoA = mkdtempSync(join(tmpdir(), "wg-iso-a-"));
|
|
3234
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: isoRepoA });
|
|
3235
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoA });
|
|
3236
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoA });
|
|
3237
|
+
writeFileSync(join(isoRepoA, "seed.txt"), "loop a\n");
|
|
3238
|
+
spawnSync("git", ["add", "-A"], { cwd: isoRepoA });
|
|
3239
|
+
spawnSync("git", ["commit", "-qm", "init a"], { cwd: isoRepoA });
|
|
3240
|
+
spawnSync("git", ["switch", "-c", "feat/iso-a"], { cwd: isoRepoA });
|
|
3241
|
+
const isoRepoB = mkdtempSync(join(tmpdir(), "wg-iso-b-"));
|
|
3242
|
+
spawnSync("git", ["init", "-b", "loop/other-loop"], { cwd: isoRepoB });
|
|
3243
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoB });
|
|
3244
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoB });
|
|
3245
|
+
writeFileSync(join(isoRepoB, "seed.txt"), "loop b\n");
|
|
3246
|
+
spawnSync("git", ["add", "-A"], { cwd: isoRepoB });
|
|
3247
|
+
spawnSync("git", ["commit", "-qm", "init b"], { cwd: isoRepoB });
|
|
3248
|
+
const isoPluginA = await WorkflowGuard({ directory: isoRepoA, worktree: isoRepoA, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
3249
|
+
const isoPluginB = await WorkflowGuard({ directory: isoRepoB, worktree: isoRepoB, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
|
|
3250
|
+
const isoHookOf = (plugin: unknown) => (plugin as { "tool.execute.before"?: (input: unknown, output: unknown) => Promise<unknown> })["tool.execute.before"];
|
|
3251
|
+
// Session A binds to repoA from its OWN call context (an edit/read target
|
|
3252
|
+
// inside repoA delivered through the call arguments).
|
|
3253
|
+
await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-shared", tool: "read", callID: "c-iso-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
|
|
3254
|
+
check("a session binds to its workspace from its own call context", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3255
|
+
// The other loop's plugin instance must not re-anchor it from its own root.
|
|
3256
|
+
await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
|
|
3257
|
+
check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
|
|
3258
|
+
const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
|
|
3259
|
+
check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
|
|
3260
|
+
const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
|
|
3261
|
+
check("guard_status honors an explicit directory over the session binding", isoStatusExplicit.workspaceRoot === isoRepoB && isoStatusExplicit.branch === "loop/other-loop");
|
|
3262
|
+
check("resolveEffectiveWorkspace keeps a bound session off a foreign fallback", (await resolveEffectiveWorkspace({ sessionID: "s-iso-shared", fallback: isoRepoB })) === isoRepoA);
|
|
3263
|
+
check("resolveEffectiveWorkspace keeps fallback semantics for an unbound session", (await resolveEffectiveWorkspace({ sessionID: "s-iso-unbound", fallback: isoRepoB })) === isoRepoB);
|
|
3264
|
+
// Policy decisions follow the call's own context: a boundary write inside the
|
|
3265
|
+
// session's repo passes even while the other loop's root is the fallback.
|
|
3266
|
+
todo("s-iso-shared", item("iso work", "in_progress"));
|
|
3267
|
+
const isoWrite = await guardToolDecision("write", { filePath: join(isoRepoA, "scratch.txt"), content: "safe" }, { sessionID: "s-iso-shared" });
|
|
3268
|
+
check("boundary checks use the session's own workspace, not the other loop's root", isoWrite.status === "allowed");
|
|
3269
|
+
if (isoWrite.status !== "allowed") console.log(" blocked:", isoWrite.message);
|
|
3270
|
+
// Review verdict binding is never guessed: no explicit directory, no session
|
|
3271
|
+
// binding, no per-call worktree — the verdict is rejected, not latched.
|
|
3272
|
+
const unboundReview = await isoPluginA.tool?.record_review?.execute(
|
|
3273
|
+
{ reviewer: "no-binding", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
|
|
3274
|
+
{ sessionID: "s-iso-unbound-recorder" } as any,
|
|
3275
|
+
);
|
|
3276
|
+
check(
|
|
3277
|
+
"record_review rejects a verdict it cannot bind instead of latching a root",
|
|
3278
|
+
typeof unboundReview === "string" && unboundReview.includes("rejected") && unboundReview.includes("never bound to a guessed root") && !unboundReview.includes("APPROVED"),
|
|
3279
|
+
);
|
|
3280
|
+
const unboundReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-iso-unbound-recorder");
|
|
3281
|
+
check("unbindingable verdict audited as review_binding_unresolved", unboundReviewAudit?.reason === "review_binding_unresolved");
|
|
3282
|
+
// A bound session records through the OTHER loop's plugin instance: the
|
|
3283
|
+
// verdict binds to the session's own workspace, not the instance root.
|
|
3284
|
+
const isoBoundReview = await isoPluginB.tool?.record_review?.execute(
|
|
3285
|
+
{ reviewer: "drift-proof", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
|
|
3286
|
+
{ sessionID: "s-iso-shared" } as any,
|
|
3287
|
+
);
|
|
3288
|
+
check(
|
|
3289
|
+
"record_review binds to the session's own workspace, not the other loop's instance root",
|
|
3290
|
+
typeof isoBoundReview === "string" && isoBoundReview.includes("APPROVED") && isoBoundReview.includes(isoRepoA) && !isoBoundReview.includes(isoRepoB),
|
|
3291
|
+
);
|
|
3292
|
+
rmSync(isoRepoA, { recursive: true, force: true });
|
|
3293
|
+
rmSync(isoRepoB, { recursive: true, force: true });
|
|
3294
|
+
resetReviewState();
|
|
3295
|
+
setWorkspaceRoot(root);
|
|
3296
|
+
|
|
3297
|
+
// Review bindings are consumed per WORKTREE, not per repository-recency: two
|
|
3298
|
+
// loops sharing one repo must not displace each other's approvals between
|
|
3299
|
+
// record_review and PR creation.
|
|
3300
|
+
const raceRepo = join(root, "wg-race-repo");
|
|
3301
|
+
mkdirSync(raceRepo, { recursive: true });
|
|
3302
|
+
spawnSync("git", ["init", "-b", "main"], { cwd: raceRepo });
|
|
3303
|
+
spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: raceRepo });
|
|
3304
|
+
spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: raceRepo });
|
|
3305
|
+
writeFileSync(join(raceRepo, "seed.txt"), "base\n");
|
|
3306
|
+
spawnSync("git", ["add", "-A"], { cwd: raceRepo });
|
|
3307
|
+
spawnSync("git", ["commit", "-qm", "base"], { cwd: raceRepo });
|
|
3308
|
+
mkdirSync(join(raceRepo, ".opencode"), { recursive: true });
|
|
3309
|
+
writeFileSync(join(raceRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
|
|
3310
|
+
spawnSync("git", ["switch", "-c", "feat/race-a"], { cwd: raceRepo });
|
|
3311
|
+
writeFileSync(join(raceRepo, "a.txt"), "loop a change\n");
|
|
3312
|
+
spawnSync("git", ["add", "-A"], { cwd: raceRepo });
|
|
3313
|
+
spawnSync("git", ["commit", "-qm", "loop a change"], { cwd: raceRepo });
|
|
3314
|
+
const prevWorktreeDirRace = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
|
|
3315
|
+
process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-race-worktrees");
|
|
3316
|
+
const raceWtRes = createGitWorktree("feat/race-b", "HEAD", raceRepo);
|
|
3317
|
+
check("race: sibling worktree created", raceWtRes.success && typeof raceWtRes.worktreePath === "string");
|
|
3318
|
+
const raceWt = raceWtRes.worktreePath!;
|
|
3319
|
+
writeFileSync(join(raceWt, "b.txt"), "loop b change\n");
|
|
3320
|
+
spawnSync("git", ["add", "-A"], { cwd: raceWt });
|
|
3321
|
+
spawnSync("git", ["commit", "-qm", "loop b change"], { cwd: raceWt });
|
|
3322
|
+
const raceSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
|
|
3323
|
+
// Loop A records first; loop B records a NEWER review for the sibling
|
|
3324
|
+
// worktree. Each worktree must keep consuming its OWN approval.
|
|
3325
|
+
resetReviewState();
|
|
3326
|
+
recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
|
|
3327
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3328
|
+
check("loop A consumes its own worktree review, not the newer sibling review", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
|
|
3329
|
+
check("loop B consumes its own worktree review", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
|
|
3330
|
+
resetReviewState();
|
|
3331
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3332
|
+
recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
|
|
3333
|
+
check("loop A keeps its own review when it was recorded first", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
|
|
3334
|
+
check("loop B keeps its own review when it was recorded last", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
|
|
3335
|
+
// Preflight consumption with the remote state stubbed off: loop A's PR must
|
|
3336
|
+
// pass while the sibling loop's newer review exists, and must FAIL with a
|
|
3337
|
+
// worktree-mismatch cause when only the sibling review is present.
|
|
3338
|
+
const racePrevPath = process.env.PATH;
|
|
3339
|
+
const raceBin = join(root, "wg-race-bin");
|
|
3340
|
+
mkdirSync(raceBin, { recursive: true });
|
|
3341
|
+
writeFileSync(join(raceBin, "gh"), "#!/bin/sh\nexit 1\n");
|
|
3342
|
+
writeFileSync(join(raceBin, "az"), "#!/bin/sh\nexit 1\n");
|
|
3343
|
+
chmodSync(join(raceBin, "gh"), 0o755);
|
|
3344
|
+
chmodSync(join(raceBin, "az"), 0o755);
|
|
3345
|
+
process.env.PATH = `${raceBin}:${racePrevPath ?? ""}`;
|
|
3346
|
+
const racePrBody = "gh pr create --title 'fix: race' --body 'Changelog: race'";
|
|
3347
|
+
try {
|
|
3348
|
+
const racePrA = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
|
|
3349
|
+
check("loop A's PR passes preflight while the sibling loop's newer review exists", !blocked(racePrA));
|
|
3350
|
+
if (blocked(racePrA)) console.log(" blocked:", racePrA);
|
|
3351
|
+
const racePrB = await call("bash", { command: racePrBody, workdir: raceWt }, { sessionID: "s-race-b", worktree: raceWt, directory: raceWt });
|
|
3352
|
+
check("loop B's PR passes preflight independently", !blocked(racePrB));
|
|
3353
|
+
if (blocked(racePrB)) console.log(" blocked:", racePrB);
|
|
3354
|
+
// Negative control: only the sibling review present -> the mismatch is
|
|
3355
|
+
// named instead of displacing the other loop's evidence.
|
|
3356
|
+
resetReviewState();
|
|
3357
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3358
|
+
const racePrOnlyB = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
|
|
3359
|
+
check(
|
|
3360
|
+
"loop A's PR is blocked on the sibling review alone, naming the worktree mismatch",
|
|
3361
|
+
blocked(racePrOnlyB) && String(racePrOnlyB).includes("does not match the current worktree contents"),
|
|
3362
|
+
);
|
|
3363
|
+
// Same-repo fallback still rescues content-identical trees: a review
|
|
3364
|
+
// recorded from a worktree whose tracked content equals this one applies,
|
|
3365
|
+
// even when a NEWER sibling review does not match this content.
|
|
3366
|
+
const raceWtSameContentRes = createGitWorktree("feat/race-same", "feat/race-a", raceRepo);
|
|
3367
|
+
const raceWtSame = raceWtSameContentRes.worktreePath!;
|
|
3368
|
+
resetReviewState();
|
|
3369
|
+
recordReviewResult("reviewer-same", raceSummary, true, "s-race-same", raceRepo);
|
|
3370
|
+
recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
|
|
3371
|
+
check(
|
|
3372
|
+
"a same-repo review whose tracked content matches this worktree is preferred over a newer mismatching one",
|
|
3373
|
+
getLastReviewResultForWorkspace(raceWtSame)?.reviewer === "reviewer-same",
|
|
3374
|
+
);
|
|
3375
|
+
rmSync(raceWtSame, { recursive: true, force: true });
|
|
3376
|
+
} finally {
|
|
3377
|
+
process.env.PATH = racePrevPath;
|
|
3378
|
+
}
|
|
3379
|
+
if (prevWorktreeDirRace === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
|
|
3380
|
+
else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirRace;
|
|
3381
|
+
rmSync(join(root, "wg-race-worktrees"), { recursive: true, force: true });
|
|
3382
|
+
rmSync(raceBin, { recursive: true, force: true });
|
|
3383
|
+
rmSync(raceRepo, { recursive: true, force: true });
|
|
3384
|
+
resetReviewState();
|
|
3385
|
+
setWorkspaceRoot(root);
|
|
3386
|
+
|
|
3142
3387
|
// 14. Documentation Review & Synchronization Guard (Policy 21)
|
|
3143
3388
|
console.log("- Policy 21: Documentation Review & Synchronization Guard -");
|
|
3144
3389
|
const docRepo = mkdtempSync(join(tmpdir(), "wg-doc-repo-"));
|