gentle-pi 2.1.0 → 2.1.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/extensions/gentle-ai.ts +54 -9
- package/lib/native-review-cli.ts +62 -25
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +61 -24
- package/tests/native-review-cli.test.ts +27 -0
- package/tests/native-review-consent.test.ts +77 -0
- package/tests/native-review-parity.test.ts +80 -14
- package/tests/package-manifest.test.ts +2 -2
- package/tests/review-controller-lock-status.test.ts +25 -7
- package/tests/review-controller-native-routing.test.ts +172 -14
- package/tests/review-controller-workspace-root.test.ts +40 -10
|
@@ -5,6 +5,7 @@ import test from "node:test";
|
|
|
5
5
|
import { GENTLE_AI_VERSION } from "../lib/gentle-ai-binary.ts";
|
|
6
6
|
import {
|
|
7
7
|
NativeReviewCliV216,
|
|
8
|
+
NativeReviewConsentBindingError,
|
|
8
9
|
NativeReviewConsentRequiredError,
|
|
9
10
|
clearNativeReviewCapabilitiesCacheForTesting,
|
|
10
11
|
type ExecFileAdapter,
|
|
@@ -78,6 +79,22 @@ test("negotiated ordinary START declares relay and preserves the complete target
|
|
|
78
79
|
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
79
80
|
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
80
81
|
]);
|
|
82
|
+
assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("controller-prebound START target is used without projecting a second workspace candidate", async () => {
|
|
86
|
+
const consent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
87
|
+
const target = String(consent.target_identity);
|
|
88
|
+
const queue = queuedAdapter([capabilities(), consent]);
|
|
89
|
+
await assert.rejects(
|
|
90
|
+
() => client(queue.adapter).start({ cwd: "/repo", targetIdentity: target, projection: "workspace" }),
|
|
91
|
+
(error: unknown) => error instanceof NativeReviewConsentRequiredError,
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(queue.calls.at(-1), [
|
|
94
|
+
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
95
|
+
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
96
|
+
]);
|
|
97
|
+
assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), false, "a prebound START target must not be projected again");
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
|
|
@@ -100,6 +117,66 @@ test("consent follow-up executes the provider-named invocation exactly once and
|
|
|
100
117
|
);
|
|
101
118
|
});
|
|
102
119
|
|
|
120
|
+
// A binding mismatch is decided entirely inside Pi, before the provider is
|
|
121
|
+
// launched, so it must not be reported as a provider failure (issue #247).
|
|
122
|
+
test("a consent invocation binding mismatch is a typed pre-native error that never launches the provider", async () => {
|
|
123
|
+
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
|
|
124
|
+
const queue = queuedAdapter([]);
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => client(queue.adapter).answerConsent!({ cwd: "/repo/.git/gentle-ai/candidate-views/a1c7fdae", consent, answer: "granted" }),
|
|
127
|
+
(error: unknown) => {
|
|
128
|
+
assert.ok(error instanceof NativeReviewConsentBindingError);
|
|
129
|
+
assert.equal(error.name, "NativeReviewConsentBindingError");
|
|
130
|
+
assert.equal(error.reason, "consent-invocation-cwd-changed");
|
|
131
|
+
assert.equal(error.launchAttempted, false);
|
|
132
|
+
assert.equal(error.mutationOutcome, "none");
|
|
133
|
+
assert.match(error.message, /repository binding changed/);
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
assert.deepEqual(queue.calls, []);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// `decodeReviewConsentV2` already rejects a malformed invocation, so these
|
|
141
|
+
// guards defend against a consent object that drifted after decoding. Each one
|
|
142
|
+
// must still name itself rather than collapse into a generic failure.
|
|
143
|
+
test("every consent invocation binding guard reports its own reason without launching the provider", async () => {
|
|
144
|
+
const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
|
|
145
|
+
const drifted = (mutate: (consent: ReviewConsentV2) => void): ReviewConsentV2 => {
|
|
146
|
+
const value = structuredClone(decoded);
|
|
147
|
+
mutate(value);
|
|
148
|
+
return value;
|
|
149
|
+
};
|
|
150
|
+
const rewriteGranted = (consent: ReviewConsentV2, replace: (invocation: string) => string): void => {
|
|
151
|
+
const choice = consent.choices.find((candidate) => candidate.answer === "granted") as { invocation: string };
|
|
152
|
+
choice.invocation = replace(choice.invocation);
|
|
153
|
+
};
|
|
154
|
+
const cases = [
|
|
155
|
+
{
|
|
156
|
+
reason: "consent-answer-unknown",
|
|
157
|
+
consent: drifted((consent) => { (consent as { choices: unknown }).choices = consent.choices.filter((choice) => choice.answer !== "granted"); }),
|
|
158
|
+
},
|
|
159
|
+
{ reason: "consent-invocation-not-start", consent: drifted((consent) => rewriteGranted(consent, (value) => value.replace("review start", "review finalize"))) },
|
|
160
|
+
{ reason: "consent-invocation-contract-changed", consent: drifted((consent) => rewriteGranted(consent, (value) => value.replace("gentle-ai.review-integration/v2", "gentle-ai.review-integration/v1"))) },
|
|
161
|
+
{ reason: "consent-invocation-target-changed", consent: drifted((consent) => { (consent as { targetIdentity: string }).targetIdentity = `sha256:${"c".repeat(64)}`; }) },
|
|
162
|
+
{ reason: "consent-invocation-projection-changed", consent: drifted((consent) => { (consent as { projection: string }).projection = "staged"; }) },
|
|
163
|
+
{ reason: "consent-invocation-answer-changed", consent: drifted((consent) => rewriteGranted(consent, (value) => value.replace("--consent granted", "--consent declined"))) },
|
|
164
|
+
{ reason: "consent-invocation-option-invalid", consent: drifted((consent) => rewriteGranted(consent, (value) => `${value} --consent granted`)) },
|
|
165
|
+
] as const;
|
|
166
|
+
for (const scenario of cases) {
|
|
167
|
+
const queue = queuedAdapter([]);
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
() => client(queue.adapter).answerConsent!({ cwd: "/repo", consent: scenario.consent, answer: "granted" }),
|
|
170
|
+
(error: unknown) => {
|
|
171
|
+
assert.ok(error instanceof NativeReviewConsentBindingError, `${scenario.reason} must be a typed binding error`);
|
|
172
|
+
assert.equal(error.reason, scenario.reason);
|
|
173
|
+
return true;
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
assert.deepEqual(queue.calls, [], `${scenario.reason} must not launch the provider`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
103
180
|
test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
|
|
104
181
|
const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
105
182
|
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -9,12 +9,16 @@ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
|
9
9
|
import {
|
|
10
10
|
NATIVE_REVIEW_ERROR_CODE,
|
|
11
11
|
NativeReviewCliError,
|
|
12
|
+
NativeReviewConsentBindingError,
|
|
12
13
|
NativeReviewConsentRequiredError,
|
|
13
14
|
NativeReviewCliV213 as NativeReviewCliV213Production,
|
|
15
|
+
normalizeNativeReviewCwd,
|
|
14
16
|
setNativeCliContractForTesting,
|
|
15
17
|
type ExecFileAdapter,
|
|
16
18
|
type NativeReviewCli,
|
|
17
19
|
type NativeReviewConsentAnswer,
|
|
20
|
+
type NativeReviewConsentAnswerRequest,
|
|
21
|
+
type NativeStartRequest,
|
|
18
22
|
} from "../lib/native-review-cli.ts";
|
|
19
23
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
20
24
|
import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
|
|
@@ -112,6 +116,26 @@ test("reviewMode status uses the exact fixed argv and decodes the effective mode
|
|
|
112
116
|
assert.equal(result.scope, "both");
|
|
113
117
|
});
|
|
114
118
|
|
|
119
|
+
test("reviewMode canonicalizes an existing repository cwd before the version probe and status argv", async (t) => {
|
|
120
|
+
if (process.platform === "win32") return t.skip("directory symlink creation requires elevated Windows privileges");
|
|
121
|
+
const repository = mkdtempSync(join(tmpdir(), "gentle-pi-review-mode-cwd-"));
|
|
122
|
+
const alias = `${repository}-alias`;
|
|
123
|
+
symlinkSync(repository, alias, "dir");
|
|
124
|
+
t.after(() => { rmSync(alias, { force: true }); rmSync(repository, { recursive: true, force: true }); });
|
|
125
|
+
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { global: "off", source: "global" })) }]);
|
|
126
|
+
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: alias, operation: "status" });
|
|
127
|
+
assert.equal(result.status.effective, "off");
|
|
128
|
+
assert.equal(queue.calls.every((call) => call.cwd === repository), true);
|
|
129
|
+
assert.deepEqual(queue.calls[1]?.arguments, ["review", "mode", "status", "--cwd", repository, "--json"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("native review cwd normalization unifies Git Bash and drive-form Windows paths", () => {
|
|
133
|
+
const expected = "C:\\Users\\Alan\\worktree B";
|
|
134
|
+
assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "win32"), expected);
|
|
135
|
+
assert.equal(normalizeNativeReviewCwd("c:/Users/Alan/worktree B", "win32"), expected);
|
|
136
|
+
assert.equal(normalizeNativeReviewCwd("/c/Users/Alan/worktree B", "linux"), "/c/Users/Alan/worktree B");
|
|
137
|
+
});
|
|
138
|
+
|
|
115
139
|
test("reviewMode status decodes an off effective mode with its deciding source", async () => {
|
|
116
140
|
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
|
|
117
141
|
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
@@ -292,18 +316,22 @@ const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
|
|
|
292
316
|
authorizationSchema: "gentle-ai.review-repair-authorization/v1",
|
|
293
317
|
};
|
|
294
318
|
|
|
295
|
-
function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
319
|
+
function unrelatedStartTargetStatus(cwd: string): ReviewStatusV3 {
|
|
296
320
|
const sha = `sha256:${"a".repeat(64)}`;
|
|
297
|
-
const
|
|
321
|
+
const candidate = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
322
|
+
const tree = candidate.candidateTree;
|
|
323
|
+
const baseTree = candidate.baseTree;
|
|
324
|
+
const paths = candidate.paths;
|
|
325
|
+
candidate.cleanup();
|
|
298
326
|
const projection = {
|
|
299
327
|
schema: "gentle-ai.review-integration.projection/v1" as const,
|
|
300
328
|
kind: "current-changes" as const,
|
|
301
329
|
projection: "workspace" as const,
|
|
302
|
-
baseTree
|
|
330
|
+
baseTree,
|
|
303
331
|
initialReviewTree: tree,
|
|
304
332
|
currentCandidateTree: tree,
|
|
305
333
|
pathsDigest: sha,
|
|
306
|
-
paths
|
|
334
|
+
paths,
|
|
307
335
|
intendedUntracked: [],
|
|
308
336
|
intendedUntrackedProof: sha,
|
|
309
337
|
initialSnapshotIdentity: sha,
|
|
@@ -333,7 +361,7 @@ function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
|
333
361
|
schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
|
|
334
362
|
applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
|
|
335
363
|
repair: rawRepair,
|
|
336
|
-
projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree:
|
|
364
|
+
projection: { schema: projection.schema, kind: projection.kind, projection: projection.projection, base_tree: baseTree, initial_review_tree: tree, current_candidate_tree: tree, paths_digest: sha, paths, intended_untracked: [], intended_untracked_proof: sha, initial_snapshot_identity: sha, current_snapshot_identity: sha },
|
|
337
365
|
candidates: [],
|
|
338
366
|
},
|
|
339
367
|
};
|
|
@@ -369,8 +397,8 @@ function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: Na
|
|
|
369
397
|
async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
|
|
370
398
|
async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
|
|
371
399
|
async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
|
|
372
|
-
async targetStatus() {
|
|
373
|
-
return unrelatedStartTargetStatus();
|
|
400
|
+
async targetStatus(request: { cwd: string }) {
|
|
401
|
+
return unrelatedStartTargetStatus(request.cwd);
|
|
374
402
|
},
|
|
375
403
|
...(reviewModeCapable
|
|
376
404
|
? {
|
|
@@ -534,17 +562,23 @@ function candidateConsent(cwd: string): ReviewConsentV2 {
|
|
|
534
562
|
return { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", riskEvidence: ["shell process"], choices, offPath: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" }, raw };
|
|
535
563
|
}
|
|
536
564
|
|
|
537
|
-
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[] } {
|
|
565
|
+
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
|
|
538
566
|
const { native } = fakeOrganicNative();
|
|
539
567
|
const consent = candidateConsent(cwd);
|
|
540
568
|
const answers: NativeReviewConsentAnswer[] = [];
|
|
541
|
-
|
|
569
|
+
const startRequests: NativeStartRequest[] = [];
|
|
570
|
+
const answerRequests: NativeReviewConsentAnswerRequest[] = [];
|
|
571
|
+
native.start = async (request) => {
|
|
572
|
+
startRequests.push(request);
|
|
573
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
574
|
+
};
|
|
542
575
|
native.answerConsent = async (request) => {
|
|
543
576
|
answers.push(request.answer);
|
|
577
|
+
answerRequests.push(request);
|
|
544
578
|
if (request.answer === "declined") return { kind: "declined", targetIdentity: consent.targetIdentity, projection: "workspace", riskLevel: "high", changedFiles: 1, changedLines: 1, consent: "declined_this_candidate", raw: { operation: "review/start", action: "declined", consent: "declined_this_candidate" } };
|
|
545
579
|
return { kind: "started", start: { lineageId: "native-lineage", state: "reviewing", riskLevel: "high", selectedLenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true } };
|
|
546
580
|
};
|
|
547
|
-
return { native, answers };
|
|
581
|
+
return { native, answers, startRequests, answerRequests };
|
|
548
582
|
}
|
|
549
583
|
|
|
550
584
|
async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
@@ -578,13 +612,23 @@ test("consent relay returns the identical complete parent-visible envelope with
|
|
|
578
612
|
test("explicit consent follow-up grants or declines exactly once", async (t) => {
|
|
579
613
|
const cwd = repository(t);
|
|
580
614
|
for (const answer of ["granted", "declined"] as const) {
|
|
581
|
-
const { native, answers } = relayedConsentNative(cwd);
|
|
615
|
+
const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
|
|
582
616
|
const { controller } = runtime(native);
|
|
583
617
|
const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
|
|
584
618
|
const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
|
|
585
619
|
assert.deepEqual(answers, [answer]);
|
|
586
|
-
|
|
587
|
-
|
|
620
|
+
assert.equal(startRequests.length, 1);
|
|
621
|
+
assert.equal(startRequests[0]?.cwd, cwd);
|
|
622
|
+
assert.equal(startRequests[0]?.targetIdentity, candidateConsent(cwd).targetIdentity);
|
|
623
|
+
assert.equal(startRequests[0]?.projection, "workspace");
|
|
624
|
+
assert.equal(answerRequests.length, 1);
|
|
625
|
+
assert.equal(answerRequests[0]?.cwd, cwd);
|
|
626
|
+
assert.equal(answerRequests[0]?.consent.targetIdentity, startRequests[0]?.targetIdentity);
|
|
627
|
+
if (answer === "granted") {
|
|
628
|
+
const actorBinding = result.actor_binding as { workspace_root: string; candidate_root: string };
|
|
629
|
+
assert.equal(actorBinding.workspace_root, cwd);
|
|
630
|
+
assert.notEqual(actorBinding.candidate_root, cwd);
|
|
631
|
+
} else {
|
|
588
632
|
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
589
633
|
assert.equal(result.lineage_created, false);
|
|
590
634
|
assert.equal(result.actor_binding, undefined);
|
|
@@ -593,6 +637,28 @@ test("explicit consent follow-up grants or declines exactly once", async (t) =>
|
|
|
593
637
|
}
|
|
594
638
|
});
|
|
595
639
|
|
|
640
|
+
// Issue #247: a local binding mismatch was indistinguishable from a provider
|
|
641
|
+
// outage, so the reporter diagnosed a missing --cwd that Pi does forward.
|
|
642
|
+
test("a consent binding mismatch surfaces as an actionable local failure, not an opaque native operation failure", async (t) => {
|
|
643
|
+
const cwd = repository(t);
|
|
644
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
645
|
+
native.answerConsent = async () => {
|
|
646
|
+
throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
|
|
647
|
+
};
|
|
648
|
+
const { controller } = runtime(native);
|
|
649
|
+
const blocked = await blockedConsent(controller, "consent-binding", headlessContext(cwd));
|
|
650
|
+
const result = await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
651
|
+
assert.equal(result.status, "blocked");
|
|
652
|
+
assert.equal(result.outcome, "consent-binding-invalid");
|
|
653
|
+
assert.deepEqual(result.diagnostics, { code: "consent-invocation-cwd-changed", message: "Native consent invocation repository binding changed" });
|
|
654
|
+
assert.equal(result.native_invocation_attempted, false);
|
|
655
|
+
assert.equal(result.lineage_created, false);
|
|
656
|
+
assert.equal(result.mutation_performed, false);
|
|
657
|
+
assert.equal(result.mutation_outcome, "none");
|
|
658
|
+
assert.equal(result.next_action, "resolve-consent-binding");
|
|
659
|
+
assert.deepEqual(answers, []);
|
|
660
|
+
});
|
|
661
|
+
|
|
596
662
|
test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
|
|
597
663
|
const cwd = repository(t);
|
|
598
664
|
const consent = candidateConsent(cwd);
|
|
@@ -1137,9 +1137,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1137
1137
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1138
1138
|
});
|
|
1139
1139
|
|
|
1140
|
-
test("v2.1.
|
|
1140
|
+
test("v2.1.2 release package and runtime stop before publication", () => {
|
|
1141
1141
|
const packageJson = readPackageJson();
|
|
1142
|
-
assert.equal(packageJson.version, "2.1.
|
|
1142
|
+
assert.equal(packageJson.version, "2.1.2", "the release manifest must remain explicitly pinned to v2.1.2");
|
|
1143
1143
|
assert.equal(
|
|
1144
1144
|
packageJson.scripts?.test,
|
|
1145
1145
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
|
|
@@ -75,11 +75,27 @@ function nativeStatus(cwd: string, status: string, locks: readonly unknown[]): N
|
|
|
75
75
|
} as NativeReviewStatusResult;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function fakeNative(status: NativeReviewStatusResult, onStart?: () => void): NativeReviewCli {
|
|
78
|
+
function fakeNative(status: NativeReviewStatusResult, onStart?: (request: Parameters<NativeReviewCli["start"]>[0]) => void): NativeReviewCli {
|
|
79
79
|
const blocking = status.locks.some((lock) => (lock as { status?: string }).status !== "released");
|
|
80
|
+
const tree = execFileSync("git", ["rev-parse", "HEAD^{tree}"], { cwd: status.repository, encoding: "utf8" }).trim();
|
|
81
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
82
|
+
const projection = {
|
|
83
|
+
schema: "gentle-ai.review-integration.projection/v1",
|
|
84
|
+
kind: "current-changes",
|
|
85
|
+
projection: "workspace",
|
|
86
|
+
baseTree: tree,
|
|
87
|
+
initialReviewTree: tree,
|
|
88
|
+
currentCandidateTree: tree,
|
|
89
|
+
pathsDigest: targetIdentity,
|
|
90
|
+
paths: [],
|
|
91
|
+
intendedUntracked: [],
|
|
92
|
+
intendedUntrackedProof: targetIdentity,
|
|
93
|
+
initialSnapshotIdentity: targetIdentity,
|
|
94
|
+
currentSnapshotIdentity: targetIdentity,
|
|
95
|
+
};
|
|
80
96
|
return {
|
|
81
|
-
start: async () => {
|
|
82
|
-
onStart?.();
|
|
97
|
+
start: async (request) => {
|
|
98
|
+
onStart?.(request);
|
|
83
99
|
return { lineageId: "native-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 2, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
84
100
|
},
|
|
85
101
|
finalize: async () => { throw new Error("finalize must not run"); },
|
|
@@ -90,7 +106,9 @@ function fakeNative(status: NativeReviewStatusResult, onStart?: () => void): Nat
|
|
|
90
106
|
targetStatus: async () => ({
|
|
91
107
|
applicability: blocking ? "corrupted" : "unrelated",
|
|
92
108
|
action: blocking ? "repair_authority" : "start",
|
|
93
|
-
|
|
109
|
+
targetIdentity,
|
|
110
|
+
projection,
|
|
111
|
+
raw: { action: blocking ? "repair_authority" : "start", locks: status.locks, target_identity: targetIdentity, projection: { projection: "workspace" } },
|
|
94
112
|
}),
|
|
95
113
|
} as unknown as NativeReviewCli;
|
|
96
114
|
}
|
|
@@ -121,11 +139,11 @@ test("INSPECT treats released lock residue as non-blocking and still blocks on l
|
|
|
121
139
|
test("START precondition ignores released lock residue and still blocks on live lock claims", async (t) => {
|
|
122
140
|
const cwd = repository(t);
|
|
123
141
|
|
|
124
|
-
|
|
125
|
-
const proceeded = await runtime(fakeNative(nativeStatus(cwd, "clean", [RELEASED_LOCK]), () => {
|
|
142
|
+
const startRequests: Parameters<NativeReviewCli["start"]>[0][] = [];
|
|
143
|
+
const proceeded = await runtime(fakeNative(nativeStatus(cwd, "clean", [RELEASED_LOCK]), (request) => { startRequests.push(request); }))
|
|
126
144
|
.execute("start-released", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
127
145
|
const proceededDetails = proceeded.details as Record<string, unknown>;
|
|
128
|
-
assert.
|
|
146
|
+
assert.deepEqual(startRequests, [{ cwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
129
147
|
assert.equal((proceededDetails.result as Record<string, unknown>).lineage_id, "native-lineage");
|
|
130
148
|
assert.notEqual(proceededDetails.outcome, "native-authority-lock-present");
|
|
131
149
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import {
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
@@ -267,7 +267,7 @@ function fakeNative(overrides: Partial<NativeReviewCli> = {}): NativeReviewCli {
|
|
|
267
267
|
targetStatus: async (request) => {
|
|
268
268
|
const lineageId = request.lineageId ?? "";
|
|
269
269
|
return lineageId === ""
|
|
270
|
-
?
|
|
270
|
+
? candidateStartTargetStatus(request)
|
|
271
271
|
: targetStatusFixture({ lineageId });
|
|
272
272
|
},
|
|
273
273
|
...overrides,
|
|
@@ -378,6 +378,28 @@ function targetStatusFixture(options: {
|
|
|
378
378
|
};
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
function candidateStartTargetStatus(request: Parameters<NonNullable<NativeReviewCli["targetStatus"]>>[0]): ReviewStatusV3 {
|
|
382
|
+
let candidate: ReturnType<CandidateViewRegistry["create"]> | undefined;
|
|
383
|
+
try {
|
|
384
|
+
candidate = new CandidateViewRegistry().create({
|
|
385
|
+
contributorRoot: request.cwd,
|
|
386
|
+
...(request.baseRef === undefined ? {} : { baseRef: request.baseRef, committedOnly: true }),
|
|
387
|
+
});
|
|
388
|
+
return targetStatusFixture({
|
|
389
|
+
applicability: "unrelated",
|
|
390
|
+
action: "start",
|
|
391
|
+
baseTree: candidate.baseTree,
|
|
392
|
+
currentCandidateTree: candidate.candidateTree,
|
|
393
|
+
paths: candidate.paths,
|
|
394
|
+
projection: request.projection ?? "workspace",
|
|
395
|
+
});
|
|
396
|
+
} catch {
|
|
397
|
+
return targetStatusFixture({ applicability: "unrelated", action: "start" });
|
|
398
|
+
} finally {
|
|
399
|
+
candidate?.cleanup();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
381
403
|
function bindReviewerManifest(status: ReviewStatusV3, cwd: string, manifestHash = `sha256:${"7".repeat(64)}`): ReviewStatusV3 {
|
|
382
404
|
const manifest = deriveChangedPathManifest(cwd, status.projection.baseTree, status.projection.currentCandidateTree).map((entry) => ({
|
|
383
405
|
...entry,
|
|
@@ -613,7 +635,7 @@ test("fresh registry reload restores the native resumed lineage only while the l
|
|
|
613
635
|
stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false,
|
|
614
636
|
}));
|
|
615
637
|
native.targetStatus = async (request) => request.lineageId === undefined
|
|
616
|
-
?
|
|
638
|
+
? candidateStartTargetStatus(request)
|
|
617
639
|
: targetStatusFixture({ lineageId: request.lineageId });
|
|
618
640
|
const { controller, toolCall } = runtime(native, undefined, undefined, undefined, candidateViews);
|
|
619
641
|
await controller.execute("reload-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
@@ -860,10 +882,10 @@ test("ambiguous native START runs target status first and follows only its decla
|
|
|
860
882
|
let statuses = 0;
|
|
861
883
|
const reconciled = targetStatusFixture({ action: "finalize", lineageId: "resumed-lineage" });
|
|
862
884
|
const { controller } = runtime(fakeNative({
|
|
863
|
-
targetStatus: async () => {
|
|
885
|
+
targetStatus: async (request) => {
|
|
864
886
|
calls.push("status");
|
|
865
887
|
statuses += 1;
|
|
866
|
-
return statuses === 1 ?
|
|
888
|
+
return statuses === 1 ? candidateStartTargetStatus(request) : reconciled;
|
|
867
889
|
},
|
|
868
890
|
start: async (request) => {
|
|
869
891
|
calls.push("start");
|
|
@@ -886,7 +908,9 @@ test("ambiguous native START runs target status first and follows only its decla
|
|
|
886
908
|
authority_applicability: "current_target",
|
|
887
909
|
provider_action: "finalize",
|
|
888
910
|
});
|
|
889
|
-
|
|
911
|
+
assert.equal(requests[0]?.cwd, cwd);
|
|
912
|
+
const replayKey = JSON.stringify({ cwd, lineageId: null, input: request.input, inputPath: null });
|
|
913
|
+
candidateViews.createOrReuse({ contributorRoot: cwd, replayKey }).cleanup();
|
|
890
914
|
});
|
|
891
915
|
|
|
892
916
|
test("ambiguous native FINALIZE returns the target-status action without a second mutation", async (t) => {
|
|
@@ -1558,8 +1582,8 @@ test("native START uses the default policy or a canonical safe policy path, and
|
|
|
1558
1582
|
await controller.execute("default-policy", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1559
1583
|
await controller.execute("custom-policy", { operation: "start", input: JSON.stringify({ mode: "ordinary", policyPath: ".gentle-ai/policies/team policy.json" }) }, undefined, undefined, context(cwd));
|
|
1560
1584
|
assert.deepEqual(requests, [
|
|
1561
|
-
{ cwd },
|
|
1562
|
-
{ cwd, policyPath },
|
|
1585
|
+
{ cwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" },
|
|
1586
|
+
{ cwd, policyPath, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" },
|
|
1563
1587
|
]);
|
|
1564
1588
|
for (const [input, outcome, reason] of [
|
|
1565
1589
|
[{ mode: "ordinary", policyHash: "legacy" }, "native-start-legacy-policy-hash-unsupported", "legacy-policy-hash-unsupported"],
|
|
@@ -1595,12 +1619,18 @@ test("native START preserves the default dirty-inclusive candidate without base
|
|
|
1595
1619
|
return { lineageId: "default-dirty-lineage", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 2, changedLines: 2, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
1596
1620
|
},
|
|
1597
1621
|
}), undefined, undefined, undefined, candidateViews);
|
|
1598
|
-
await controller.execute("default-dirty", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1622
|
+
const started = await controller.execute("default-dirty", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1599
1623
|
const view = candidateViews.resolveForLens("default-dirty-lineage", "review-reliability");
|
|
1600
1624
|
try {
|
|
1601
1625
|
assert.deepEqual(view.paths, ["app.ts", "untracked.ts"]);
|
|
1602
1626
|
assert.equal(view.committedOnly, false);
|
|
1603
|
-
assert.deepEqual(requests, [{ cwd:
|
|
1627
|
+
assert.deepEqual(requests, [{ cwd, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
1628
|
+
const actorBinding = (started.details as { actor_binding: { workspace_root: string; candidate_root: string; candidate_tree: string; candidate_paths: readonly string[] } }).actor_binding;
|
|
1629
|
+
assert.equal(actorBinding.workspace_root, cwd);
|
|
1630
|
+
assert.equal(actorBinding.candidate_root, view.root);
|
|
1631
|
+
assert.notEqual(actorBinding.candidate_root, requests[0]?.cwd);
|
|
1632
|
+
assert.equal(actorBinding.candidate_tree, view.candidateTree);
|
|
1633
|
+
assert.deepEqual(actorBinding.candidate_paths, view.paths);
|
|
1604
1634
|
} finally {
|
|
1605
1635
|
view.cleanup();
|
|
1606
1636
|
}
|
|
@@ -1626,12 +1656,68 @@ test("native START binds an acknowledged committed range and native identity to
|
|
|
1626
1656
|
assert.deepEqual(view.paths, ["committed-after-base.ts"]);
|
|
1627
1657
|
assert.equal(view.committedOnly, true);
|
|
1628
1658
|
assert.equal(view.baseCommit, baseCommit);
|
|
1629
|
-
assert.deepEqual(requests, [{ cwd
|
|
1659
|
+
assert.deepEqual(requests, [{ cwd, baseRef: view.baseCommit, committedOnly: true, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
1630
1660
|
} finally {
|
|
1631
1661
|
view.cleanup();
|
|
1632
1662
|
}
|
|
1633
1663
|
});
|
|
1634
1664
|
|
|
1665
|
+
test("native START fails closed before mutation when the workspace target and immutable candidate view differ", async (t) => {
|
|
1666
|
+
const cwd = repository(t);
|
|
1667
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
1668
|
+
let starts = 0;
|
|
1669
|
+
const { controller } = runtime(fakeNative({
|
|
1670
|
+
targetStatus: async () => targetStatusFixture({
|
|
1671
|
+
applicability: "unrelated",
|
|
1672
|
+
action: "start",
|
|
1673
|
+
baseTree: git(cwd, "rev-parse", "HEAD^{tree}"),
|
|
1674
|
+
currentCandidateTree: "b".repeat(40),
|
|
1675
|
+
paths: ["app.ts"],
|
|
1676
|
+
}),
|
|
1677
|
+
start: async () => {
|
|
1678
|
+
starts += 1;
|
|
1679
|
+
return { lineageId: "must-not-start", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
1680
|
+
},
|
|
1681
|
+
}), undefined, undefined, undefined, new CandidateViewRegistry());
|
|
1682
|
+
const result = await controller.execute("target-view-drift", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1683
|
+
assert.equal((result.details as { outcome: string }).outcome, "native-operation-failed");
|
|
1684
|
+
assert.deepEqual((result.details as { diagnostics: unknown }).diagnostics, {
|
|
1685
|
+
code: "candidate-target-projection-drift",
|
|
1686
|
+
message: "candidate view rejected before native START",
|
|
1687
|
+
});
|
|
1688
|
+
assert.equal(starts, 0);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
test("native START re-verifies candidate-view integrity before granting workspace authority", async (t) => {
|
|
1692
|
+
const cwd = repository(t);
|
|
1693
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 2;\n");
|
|
1694
|
+
class DriftingCandidateViewRegistry extends CandidateViewRegistry {
|
|
1695
|
+
override createOrReuse(request: Parameters<CandidateViewRegistry["createOrReuse"]>[0]): ReturnType<CandidateViewRegistry["createOrReuse"]> {
|
|
1696
|
+
const candidate = super.createOrReuse(request);
|
|
1697
|
+
chmodSync(candidate.root, 0o755);
|
|
1698
|
+
chmodSync(join(candidate.root, "app.ts"), 0o644);
|
|
1699
|
+
writeFileSync(join(candidate.root, "app.ts"), "corrupted frozen content\n");
|
|
1700
|
+
chmodSync(join(candidate.root, "app.ts"), 0o444);
|
|
1701
|
+
chmodSync(candidate.root, 0o555);
|
|
1702
|
+
return candidate;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
let starts = 0;
|
|
1706
|
+
const { controller } = runtime(fakeNative({
|
|
1707
|
+
start: async () => {
|
|
1708
|
+
starts += 1;
|
|
1709
|
+
return { lineageId: "must-not-start", state: "reviewing", riskLevel: "medium", selectedLenses: ["review-reliability"], changedFiles: 1, changedLines: 1, correctionBudget: 1, action: "created", lensesRequired: true };
|
|
1710
|
+
},
|
|
1711
|
+
}), undefined, undefined, undefined, new DriftingCandidateViewRegistry());
|
|
1712
|
+
const result = await controller.execute("candidate-view-drift", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, context(cwd));
|
|
1713
|
+
assert.equal((result.details as { outcome: string }).outcome, "native-operation-failed");
|
|
1714
|
+
assert.deepEqual((result.details as { diagnostics: unknown }).diagnostics, {
|
|
1715
|
+
code: "candidate-view-invalid",
|
|
1716
|
+
message: "candidate view rejected before native START",
|
|
1717
|
+
});
|
|
1718
|
+
assert.equal(starts, 0);
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1635
1721
|
test("native START rejects an unresolvable explicit base before native mutation", async (t) => {
|
|
1636
1722
|
const cwd = repository(t);
|
|
1637
1723
|
let starts = 0;
|
|
@@ -1698,7 +1784,7 @@ test("native START forwards an acknowledged base ref and rejects invalid values
|
|
|
1698
1784
|
},
|
|
1699
1785
|
}));
|
|
1700
1786
|
await controller.execute("committed-base", { operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef: "refs/heads/main", committedOnly: true }) }, undefined, undefined, context(cwd));
|
|
1701
|
-
assert.deepEqual(requests, [{ cwd, baseRef: git(cwd, "rev-parse", "refs/heads/main"), committedOnly: true }]);
|
|
1787
|
+
assert.deepEqual(requests, [{ cwd, baseRef: git(cwd, "rev-parse", "refs/heads/main"), committedOnly: true, targetIdentity: `sha256:${"a".repeat(64)}`, projection: "workspace" }]);
|
|
1702
1788
|
for (const baseRef of ["", " ", " origin/main", "origin/main ", "origin\0main", "origin\nmain", "origin\rmain", "origin\tmain", "origin\u007fmain", 42, [], {}]) {
|
|
1703
1789
|
const rejected = await controller.execute("invalid-base", { operation: "start", input: JSON.stringify({ mode: "ordinary", baseRef }) }, undefined, undefined, context(cwd));
|
|
1704
1790
|
assert.deepEqual(rejected.details, {
|
|
@@ -4032,7 +4118,7 @@ test("RECOVER rechecks a committed range against its frozen base instead of the
|
|
|
4032
4118
|
const { controller } = runtime(fakeNative({
|
|
4033
4119
|
targetStatus: async (request) => {
|
|
4034
4120
|
statusRequests.push(request as Record<string, unknown>);
|
|
4035
|
-
if (request.lineageId === undefined) return
|
|
4121
|
+
if (request.lineageId === undefined) return candidateStartTargetStatus(request);
|
|
4036
4122
|
assert.equal(request.baseRef, baseRef);
|
|
4037
4123
|
const status = targetStatusFixture({ lineageId: "native-lineage", action: "recover" });
|
|
4038
4124
|
return { ...status, actionDisposition: "invalidated", authority: { ...status.authority!, revision: "rev-1" } };
|
|
@@ -4097,6 +4183,78 @@ test("out-of-band review-mode disable discards stale lifecycle authorization and
|
|
|
4097
4183
|
assert.equal(validations, 1, "disabled organic delivery must not reuse or revalidate stale review authority");
|
|
4098
4184
|
});
|
|
4099
4185
|
|
|
4186
|
+
test("RDD-off commit and push canonicalize a git -C linked-worktree target before native mode reconsult", async (t) => {
|
|
4187
|
+
const sessionCwd = repository(t);
|
|
4188
|
+
const worktreeParent = mkdtempSync(join(tmpdir(), "gentle-pi-lifecycle-worktrees-"));
|
|
4189
|
+
const worktree = join(worktreeParent, "worktree B");
|
|
4190
|
+
const worktreeAlias = join(worktreeParent, "worktree B alias");
|
|
4191
|
+
git(sessionCwd, "worktree", "add", "-b", "issue-246-worktree", worktree);
|
|
4192
|
+
const windowsDrive = /^([A-Za-z]):[\\/](.*)$/.exec(worktree);
|
|
4193
|
+
const worktreeSpelling = process.platform === "win32"
|
|
4194
|
+
? `/${windowsDrive?.[1]?.toLowerCase()}/${windowsDrive?.[2]?.replaceAll("\\", "/")}`
|
|
4195
|
+
: worktreeAlias;
|
|
4196
|
+
if (process.platform === "win32") assert.ok(windowsDrive, "Windows worktree must have a drive-qualified path");
|
|
4197
|
+
else symlinkSync(worktree, worktreeAlias, "dir");
|
|
4198
|
+
t.after(() => {
|
|
4199
|
+
try { git(sessionCwd, "worktree", "remove", "--force", worktree); } catch {}
|
|
4200
|
+
rmSync(worktreeParent, { recursive: true, force: true });
|
|
4201
|
+
});
|
|
4202
|
+
const canonicalWorktree = realpathSync(worktree);
|
|
4203
|
+
const commonDirectory = (cwd: string): string => realpathSync(resolve(cwd, git(cwd, "rev-parse", "--git-common-dir")));
|
|
4204
|
+
assert.equal(commonDirectory(sessionCwd), commonDirectory(canonicalWorktree));
|
|
4205
|
+
const parsed = __testing.resolveReviewLifecycleCommand(`git -C "${worktreeSpelling}" commit -m "issue 246"`, sessionCwd);
|
|
4206
|
+
assert.deepEqual(parsed?.gitGlobalArgs, ["-C", worktreeSpelling], "cwd canonicalization must preserve the exact typed Git selector");
|
|
4207
|
+
|
|
4208
|
+
const nativeCalls: Array<{ arguments: readonly string[]; cwd: string }> = [];
|
|
4209
|
+
const native = new NativeReviewCliV214(async (request) => {
|
|
4210
|
+
nativeCalls.push({ arguments: request.arguments, cwd: request.cwd });
|
|
4211
|
+
if (request.cwd !== canonicalWorktree) throw new Error(`native process cwd was not canonical: ${request.cwd}`);
|
|
4212
|
+
if (request.arguments[0] === "version") {
|
|
4213
|
+
return { stdout: "gentle-ai 2.2.2\n", stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
4214
|
+
}
|
|
4215
|
+
if (request.arguments[0] === "review" && request.arguments[1] === "mode") {
|
|
4216
|
+
return {
|
|
4217
|
+
stdout: JSON.stringify({
|
|
4218
|
+
schema: "gentle-ai.review-mode/v1",
|
|
4219
|
+
operation: "status",
|
|
4220
|
+
scope: "both",
|
|
4221
|
+
status: { schema: "gentle-ai.rdd-mode-status/v1", global: "off", clone_local: "off", effective: "off", source: "clone_local" },
|
|
4222
|
+
}),
|
|
4223
|
+
stderr: "",
|
|
4224
|
+
exitCode: 0,
|
|
4225
|
+
signal: null,
|
|
4226
|
+
timedOut: false,
|
|
4227
|
+
outputLimitExceeded: false,
|
|
4228
|
+
};
|
|
4229
|
+
}
|
|
4230
|
+
throw new Error(`unexpected native review operation: ${request.arguments.join(" ")}`);
|
|
4231
|
+
});
|
|
4232
|
+
const { toolCall } = runtime(native);
|
|
4233
|
+
for (const command of [
|
|
4234
|
+
`git -C "${worktreeSpelling}" commit -m "issue 246"`,
|
|
4235
|
+
`git -C "${worktreeSpelling}" push origin issue-246-worktree`,
|
|
4236
|
+
]) {
|
|
4237
|
+
assert.equal(await toolCall({ toolName: "bash", input: { command } }, interactiveContext(sessionCwd)), undefined);
|
|
4238
|
+
}
|
|
4239
|
+
assert.equal(nativeCalls.length, 4);
|
|
4240
|
+
assert.equal(nativeCalls.every((call) => call.cwd === canonicalWorktree), true);
|
|
4241
|
+
assert.deepEqual(nativeCalls.filter((call) => call.arguments[0] === "review").map((call) => call.arguments), [
|
|
4242
|
+
["review", "mode", "status", "--cwd", canonicalWorktree, "--json"],
|
|
4243
|
+
["review", "mode", "status", "--cwd", canonicalWorktree, "--json"],
|
|
4244
|
+
]);
|
|
4245
|
+
assert.equal(nativeCalls.some((call) => call.arguments.includes("validate")), false);
|
|
4246
|
+
});
|
|
4247
|
+
|
|
4248
|
+
test("git -C linked-worktree lifecycle commands remain fail-closed when mode reconsult genuinely fails", async (t) => {
|
|
4249
|
+
const sessionCwd = repository(t);
|
|
4250
|
+
const { toolCall } = runtime(fakeNative({ reviewMode: async () => { throw new Error("mode unavailable"); } }));
|
|
4251
|
+
for (const command of ["git -C . commit -m failure", "git -C . push origin main"]) {
|
|
4252
|
+
const result = await toolCall({ toolName: "bash", input: { command } }, interactiveContext(sessionCwd)) as { block: boolean; reason: string };
|
|
4253
|
+
assert.equal(result.block, true);
|
|
4254
|
+
assert.match(result.reason, /could not reconsult review mode and failed closed/);
|
|
4255
|
+
}
|
|
4256
|
+
});
|
|
4257
|
+
|
|
4100
4258
|
test("successful /gentle:review-mode disable clears pending authorizations even after mode is re-enabled", async (t) => {
|
|
4101
4259
|
const cwd = repository(t);
|
|
4102
4260
|
let effective: "on" | "off" = "on";
|