gentle-pi 2.0.0 → 2.1.1
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/README.md +3 -12
- package/extensions/gentle-ai.ts +362 -123
- package/lib/gentle-ai-binary.ts +64 -17
- package/lib/native-review-cli.ts +207 -27
- package/lib/review-candidate-view.ts +9 -5
- package/lib/review-integration-v2.ts +5 -1
- package/package.json +1 -1
- package/runtime/gentle-ai-binary.mjs +64 -17
- package/runtime/native-review-cli.mjs +206 -26
- package/runtime/review-integration-v2.mjs +5 -1
- package/scripts/gentle-ai-installer.mjs +454 -71
- package/scripts/test-packed-runner.mjs +1 -1
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/tests/gentle-ai-binary.test.ts +70 -3
- package/tests/gentle-ai-installer.test.ts +543 -7
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-review-consent.test.ts +205 -0
- package/tests/native-review-parity-runtime.test.ts +15 -6
- package/tests/native-review-parity.test.ts +228 -53
- package/tests/package-manifest.test.ts +10 -3
- package/tests/review-candidate-view.test.ts +43 -0
- package/tests/review-controller-lock-status.test.ts +25 -7
- package/tests/review-controller-native-routing.test.ts +419 -20
- package/tests/review-controller-workspace-root.test.ts +40 -10
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-integration-v2.test.ts +10 -1
- package/tests/runtime-harness.mjs +24 -12
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { GENTLE_AI_VERSION } from "../lib/gentle-ai-binary.ts";
|
|
6
|
+
import {
|
|
7
|
+
NativeReviewCliV216,
|
|
8
|
+
NativeReviewConsentBindingError,
|
|
9
|
+
NativeReviewConsentRequiredError,
|
|
10
|
+
clearNativeReviewCapabilitiesCacheForTesting,
|
|
11
|
+
type ExecFileAdapter,
|
|
12
|
+
} from "../lib/native-review-cli.ts";
|
|
13
|
+
import type { ReviewConsentV2 } from "../lib/review-integration-v2.ts";
|
|
14
|
+
|
|
15
|
+
const fixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
|
|
16
|
+
const fixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as T;
|
|
17
|
+
const executableDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705";
|
|
18
|
+
|
|
19
|
+
function capabilities(): Record<string, unknown> {
|
|
20
|
+
const value = fixture<Record<string, unknown>>("capabilities.fixture.json");
|
|
21
|
+
(value.package as Record<string, unknown>).version = GENTLE_AI_VERSION;
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unrelatedStatus(targetIdentity: string): Record<string, unknown> {
|
|
26
|
+
const status = fixture<Record<string, unknown>>("status.fixture.json");
|
|
27
|
+
status.applicability = "unrelated";
|
|
28
|
+
status.receipt = { status: "not_applicable" };
|
|
29
|
+
status.action = "start";
|
|
30
|
+
status.replayability = "not_replayable";
|
|
31
|
+
status.target_identity = targetIdentity;
|
|
32
|
+
status.candidates = [];
|
|
33
|
+
delete status.authority;
|
|
34
|
+
delete status.frozen;
|
|
35
|
+
delete status.next_transition;
|
|
36
|
+
const projection = status.projection as Record<string, unknown>;
|
|
37
|
+
projection.initial_snapshot_identity = targetIdentity;
|
|
38
|
+
projection.current_snapshot_identity = targetIdentity;
|
|
39
|
+
return status;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function queuedAdapter(outputs: readonly Record<string, unknown>[]): { adapter: ExecFileAdapter; calls: Array<readonly string[]> } {
|
|
43
|
+
const queue = [...outputs];
|
|
44
|
+
const calls: Array<readonly string[]> = [];
|
|
45
|
+
return {
|
|
46
|
+
calls,
|
|
47
|
+
adapter: async (request) => {
|
|
48
|
+
calls.push(request.arguments);
|
|
49
|
+
const body = queue.shift();
|
|
50
|
+
if (body === undefined) throw new Error("unexpected native invocation");
|
|
51
|
+
return { stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
|
|
57
|
+
clearNativeReviewCapabilitiesCacheForTesting();
|
|
58
|
+
return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("negotiated ordinary START declares relay and preserves the complete target-bound consent envelope", async () => {
|
|
62
|
+
const consent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
63
|
+
const target = String(consent.target_identity);
|
|
64
|
+
const queue = queuedAdapter([
|
|
65
|
+
capabilities(),
|
|
66
|
+
unrelatedStatus(target),
|
|
67
|
+
consent,
|
|
68
|
+
]);
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
() => client(queue.adapter).start({ cwd: "/repo" }),
|
|
71
|
+
(error: unknown) => {
|
|
72
|
+
assert.ok(error instanceof NativeReviewConsentRequiredError);
|
|
73
|
+
assert.deepEqual(error.consent.raw, consent);
|
|
74
|
+
assert.equal(error.consent.targetIdentity, target);
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
assert.deepEqual(queue.calls.at(-1), [
|
|
79
|
+
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
80
|
+
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
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");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("consent follow-up executes the provider-named invocation exactly once and refuses a changed target binding", async () => {
|
|
101
|
+
const consent = fixture<ReviewConsentV2 extends never ? never : Record<string, unknown>>("consent.fixture.json");
|
|
102
|
+
const decodedConsent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(consent);
|
|
103
|
+
const started = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
|
|
104
|
+
.replaceAll("review-start-fixture", "review-consent-fixture")) as Record<string, unknown>;
|
|
105
|
+
const queue = queuedAdapter([capabilities(), started]);
|
|
106
|
+
const native = client(queue.adapter);
|
|
107
|
+
const result = await native.answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
|
|
108
|
+
assert.equal(result.kind, "started");
|
|
109
|
+
assert.deepEqual(queue.calls.at(-1), decodedConsent.choices[0].invocation.split(" ").slice(1));
|
|
110
|
+
assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
|
|
111
|
+
|
|
112
|
+
const changed = structuredClone(decodedConsent);
|
|
113
|
+
changed.targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
114
|
+
await assert.rejects(
|
|
115
|
+
() => native.answerConsent!({ cwd: "/repo", consent: changed, answer: "declined" }),
|
|
116
|
+
/target|binding/,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
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
|
+
|
|
180
|
+
test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
|
|
181
|
+
const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
182
|
+
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
|
|
183
|
+
const declined = {
|
|
184
|
+
operation: "review/start",
|
|
185
|
+
action: "declined",
|
|
186
|
+
lenses_required: false,
|
|
187
|
+
lineage_id: "",
|
|
188
|
+
state: "",
|
|
189
|
+
risk_level: "high",
|
|
190
|
+
selected_lenses: [],
|
|
191
|
+
lens_bindings: [],
|
|
192
|
+
projection: "workspace",
|
|
193
|
+
target_identity: consent.targetIdentity,
|
|
194
|
+
changed_files: 1,
|
|
195
|
+
changed_lines: 1,
|
|
196
|
+
correction_budget: 0,
|
|
197
|
+
risk_evidence: ["shell scripting in scripts/deploy.sh"],
|
|
198
|
+
consent: "declined_this_candidate",
|
|
199
|
+
};
|
|
200
|
+
const queue = queuedAdapter([capabilities(), declined]);
|
|
201
|
+
const result = await client(queue.adapter).answerConsent!({ cwd: "/repo", consent, answer: "declined" });
|
|
202
|
+
assert.equal(result.kind, "declined");
|
|
203
|
+
assert.equal("lineageId" in result, false);
|
|
204
|
+
assert.deepEqual(queue.calls.at(-1), consent.choices[1].invocation.split(" ").slice(1));
|
|
205
|
+
});
|
|
@@ -378,7 +378,7 @@ test("official pinned package runtime keeps frozen candidate lineages and receip
|
|
|
378
378
|
t.diagnostic("pre-push, pre-pr, and release require remote/publication evidence; their network-aware gate contracts remain covered by dedicated gate integration tests rather than this hermetic binary E2E.");
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
-
test("registered gentle_review START materializes a safe internal skill symlink
|
|
381
|
+
test("registered gentle_review START materializes a safe internal skill symlink and cleans pending consent on session shutdown", async (t) => {
|
|
382
382
|
const workspace = await mkdtemp(join(tmpdir(), "gentle-pi-v215-symlink-candidate-"));
|
|
383
383
|
const repository = join(workspace, "repository");
|
|
384
384
|
t.after(async () => rm(workspace, { recursive: true, force: true }));
|
|
@@ -412,8 +412,9 @@ test("registered gentle_review START materializes a safe internal skill symlink
|
|
|
412
412
|
return { ...command, signal: null, timedOut: false, outputLimitExceeded: false };
|
|
413
413
|
});
|
|
414
414
|
const tools = new Map<string, RegisteredController>();
|
|
415
|
+
let sessionShutdown: ((event: unknown, context: ExtensionContext) => Promise<void> | void) | undefined;
|
|
415
416
|
createGentleAiExtension({ nativeReviewCli: native, candidateViews } as Parameters<typeof createGentleAiExtension>[0])({
|
|
416
|
-
on() {},
|
|
417
|
+
on(name: string, handler: (event: unknown, context: ExtensionContext) => Promise<void> | void) { if (name === "session_shutdown") sessionShutdown = handler; },
|
|
417
418
|
registerTool(definition: RegisteredController & { name: string }) { tools.set(definition.name, definition); },
|
|
418
419
|
registerCommand() {},
|
|
419
420
|
} as unknown as ExtensionAPI);
|
|
@@ -434,8 +435,16 @@ test("registered gentle_review START materializes a safe internal skill symlink
|
|
|
434
435
|
t.diagnostic(JSON.stringify({ returned: returned?.details, error, nativeStartReached }));
|
|
435
436
|
assert.equal(thrown, undefined, "safe internal symlink materialization must not throw before START");
|
|
436
437
|
assert.equal(nativeStartReached, true, "safe internal symlink materialization must reach native START");
|
|
437
|
-
const result =
|
|
438
|
-
assert.equal(
|
|
439
|
-
assert.equal(result?.
|
|
440
|
-
|
|
438
|
+
const result = returned?.details as Record<string, unknown> | undefined;
|
|
439
|
+
assert.equal(result?.status, "blocked");
|
|
440
|
+
assert.equal(result?.outcome, "native-review-consent-required");
|
|
441
|
+
assert.equal(typeof result?.consent_binding, "string");
|
|
442
|
+
assert.equal(result?.lineage_created, false);
|
|
443
|
+
assert.ok(sessionShutdown, "extension must register session_shutdown cleanup");
|
|
444
|
+
const context = { cwd: repository, hasUI: false, ui: { notify: () => {} } } as unknown as ExtensionContext;
|
|
445
|
+
await sessionShutdown({}, context);
|
|
446
|
+
await assert.rejects(
|
|
447
|
+
controller.execute("answer-after-shutdown", { operation: "answer-consent", input: JSON.stringify({ consentBinding: result!.consent_binding, answer: "granted" }) }, undefined, undefined, context),
|
|
448
|
+
/unknown, expired, or already consumed/,
|
|
449
|
+
);
|
|
441
450
|
});
|
|
@@ -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,14 +9,20 @@ import { __testing, createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
|
9
9
|
import {
|
|
10
10
|
NATIVE_REVIEW_ERROR_CODE,
|
|
11
11
|
NativeReviewCliError,
|
|
12
|
+
NativeReviewConsentBindingError,
|
|
13
|
+
NativeReviewConsentRequiredError,
|
|
12
14
|
NativeReviewCliV213 as NativeReviewCliV213Production,
|
|
15
|
+
normalizeNativeReviewCwd,
|
|
13
16
|
setNativeCliContractForTesting,
|
|
14
17
|
type ExecFileAdapter,
|
|
15
18
|
type NativeReviewCli,
|
|
19
|
+
type NativeReviewConsentAnswer,
|
|
20
|
+
type NativeReviewConsentAnswerRequest,
|
|
21
|
+
type NativeStartRequest,
|
|
16
22
|
} from "../lib/native-review-cli.ts";
|
|
17
23
|
import { CandidateViewRegistry } from "../lib/review-candidate-view.ts";
|
|
18
|
-
import {
|
|
19
|
-
import type { AuthorityRepairAssessmentV1, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
24
|
+
import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
|
|
25
|
+
import type { AuthorityRepairAssessmentV1, ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
20
26
|
|
|
21
27
|
// Queued-adapter clients never execute a real process; default to a fixed
|
|
22
28
|
// absolute package-local path so these tests do not depend on an installed
|
|
@@ -110,6 +116,26 @@ test("reviewMode status uses the exact fixed argv and decodes the effective mode
|
|
|
110
116
|
assert.equal(result.scope, "both");
|
|
111
117
|
});
|
|
112
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
|
+
|
|
113
139
|
test("reviewMode status decodes an off effective mode with its deciding source", async () => {
|
|
114
140
|
const queue = queuedAdapter([CAPABLE_VERSION_LINE, { stdout: JSON.stringify(reviewModeStatusBody("off", { clone_local: "off", source: "clone_local", revision: "sha256:deadbeef" })) }]);
|
|
115
141
|
const result = await new NativeReviewCliV213(queue.adapter).reviewMode({ cwd: "/repo", operation: "status" });
|
|
@@ -290,18 +316,22 @@ const UNSUPPORTED_REPAIR_ASSESSMENT: AuthorityRepairAssessmentV1 = {
|
|
|
290
316
|
authorizationSchema: "gentle-ai.review-repair-authorization/v1",
|
|
291
317
|
};
|
|
292
318
|
|
|
293
|
-
function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
319
|
+
function unrelatedStartTargetStatus(cwd: string): ReviewStatusV3 {
|
|
294
320
|
const sha = `sha256:${"a".repeat(64)}`;
|
|
295
|
-
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();
|
|
296
326
|
const projection = {
|
|
297
327
|
schema: "gentle-ai.review-integration.projection/v1" as const,
|
|
298
328
|
kind: "current-changes" as const,
|
|
299
329
|
projection: "workspace" as const,
|
|
300
|
-
baseTree
|
|
330
|
+
baseTree,
|
|
301
331
|
initialReviewTree: tree,
|
|
302
332
|
currentCandidateTree: tree,
|
|
303
333
|
pathsDigest: sha,
|
|
304
|
-
paths
|
|
334
|
+
paths,
|
|
305
335
|
intendedUntracked: [],
|
|
306
336
|
intendedUntrackedProof: sha,
|
|
307
337
|
initialSnapshotIdentity: sha,
|
|
@@ -331,7 +361,7 @@ function unrelatedStartTargetStatus(): ReviewStatusV3 {
|
|
|
331
361
|
schema: "gentle-ai.review-integration.status/v3", contract: "gentle-ai.review-integration/v2", operation: "review.status",
|
|
332
362
|
applicability: "unrelated", receipt: { status: "not_applicable" }, action: "start", replayability: "not_replayable", target_identity: sha,
|
|
333
363
|
repair: rawRepair,
|
|
334
|
-
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 },
|
|
335
365
|
candidates: [],
|
|
336
366
|
},
|
|
337
367
|
};
|
|
@@ -367,8 +397,8 @@ function fakeOrganicNative(options: FakeOrganicNativeOptions = {}): { native: Na
|
|
|
367
397
|
async bindSdd(): Promise<never> { throw new Error("bindSdd not used in this test"); },
|
|
368
398
|
async sddStatus(): Promise<never> { throw new Error("sddStatus not used in this test"); },
|
|
369
399
|
async reviewStatus(): Promise<never> { throw new Error("reviewStatus not used in this test"); },
|
|
370
|
-
async targetStatus() {
|
|
371
|
-
return unrelatedStartTargetStatus();
|
|
400
|
+
async targetStatus(request: { cwd: string }) {
|
|
401
|
+
return unrelatedStartTargetStatus(request.cwd);
|
|
372
402
|
},
|
|
373
403
|
...(reviewModeCapable
|
|
374
404
|
? {
|
|
@@ -407,18 +437,23 @@ interface RegisteredCommandFixture {
|
|
|
407
437
|
handler: (args: string, ctx: ExtensionContext) => Promise<void>;
|
|
408
438
|
}
|
|
409
439
|
|
|
410
|
-
|
|
440
|
+
interface RegisteredEventFixture {
|
|
441
|
+
(event: unknown, ctx: ExtensionContext): Promise<unknown> | unknown;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function runtime(nativeReviewCli: NativeReviewCli | null): { controller: RegisteredTool; commands: Map<string, RegisteredCommandFixture>; events: Map<string, RegisteredEventFixture> } {
|
|
411
445
|
const tools = new Map<string, RegisteredTool>();
|
|
412
446
|
const commands = new Map<string, RegisteredCommandFixture>();
|
|
447
|
+
const events = new Map<string, RegisteredEventFixture>();
|
|
413
448
|
const dependencies = { nativeReviewCli, candidateViews: new CandidateViewRegistry() } as unknown as Parameters<typeof createGentleAiExtension>[0];
|
|
414
449
|
createGentleAiExtension(dependencies)({
|
|
415
|
-
on() {},
|
|
450
|
+
on(name: string, handler: RegisteredEventFixture) { events.set(name, handler); },
|
|
416
451
|
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
417
452
|
registerCommand(name: string, definition: RegisteredCommandFixture) { commands.set(name, definition); },
|
|
418
453
|
} as unknown as ExtensionAPI);
|
|
419
454
|
const controller = tools.get("gentle_review");
|
|
420
455
|
assert.ok(controller);
|
|
421
|
-
return { controller: controller!, commands };
|
|
456
|
+
return { controller: controller!, commands, events };
|
|
422
457
|
}
|
|
423
458
|
|
|
424
459
|
function headlessContext(cwd: string, notices: Array<{ message: string; type?: string }> = []): ExtensionContext {
|
|
@@ -447,6 +482,7 @@ test("kill-switch: effective off returns a non-failure skipped envelope and neve
|
|
|
447
482
|
const result = await execStart(controller, "kill-switch-off", headlessContext(cwd));
|
|
448
483
|
assert.equal(result.status, "skipped");
|
|
449
484
|
assert.equal(result.outcome, "review-mode-disabled");
|
|
485
|
+
assert.equal(result.delivery, "disabled/unmanaged");
|
|
450
486
|
assert.equal(result.mutation_performed, false);
|
|
451
487
|
assert.equal(state.startCalls, 0, "native start must not be called once the kill switch reports off");
|
|
452
488
|
});
|
|
@@ -516,67 +552,206 @@ test("kill-switch: an unexpected reviewMode failure maps to the existing native-
|
|
|
516
552
|
assert.equal(result.outcome, "native-operation-failed");
|
|
517
553
|
});
|
|
518
554
|
|
|
519
|
-
|
|
555
|
+
function candidateConsent(cwd: string): ReviewConsentV2 {
|
|
556
|
+
const targetIdentity = `sha256:${"a".repeat(64)}`;
|
|
557
|
+
const choices = [
|
|
558
|
+
{ answer: "granted" as const, label: "Run the review now", effect: "Review this exact candidate only.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent granted` },
|
|
559
|
+
{ answer: "declined" as const, label: "Not now, just this once", effect: "Create no authority and ask again next candidate.", invocation: `gentle-ai review start --contract gentle-ai.review-integration/v2 --cwd ${cwd} --target ${targetIdentity} --projection workspace --lineage native-lineage --consent declined` },
|
|
560
|
+
] as const;
|
|
561
|
+
const raw = { schema: "gentle-ai.review-integration.consent/v2", contract: "gentle-ai.review-integration/v2", operation: "review.start", action: "consent_required", blocking: true, target_identity: targetIdentity, projection: "workspace", risk_level: "high", changed_files: 1, changed_lines: 1, headline: "Review this candidate", reason: "It changes a process boundary.", value: "Review catches regressions.", risk_evidence: ["shell process"], choices: choices.map((choice) => ({ ...choice })), off_path: { note: "Disable reviews separately.", command: "gentle-ai review mode disable" } };
|
|
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 };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function relayedConsentNative(cwd: string): { native: NativeReviewCli; answers: NativeReviewConsentAnswer[]; startRequests: NativeStartRequest[]; answerRequests: NativeReviewConsentAnswerRequest[] } {
|
|
566
|
+
const { native } = fakeOrganicNative();
|
|
567
|
+
const consent = candidateConsent(cwd);
|
|
568
|
+
const answers: NativeReviewConsentAnswer[] = [];
|
|
569
|
+
const startRequests: NativeStartRequest[] = [];
|
|
570
|
+
const answerRequests: NativeReviewConsentAnswerRequest[] = [];
|
|
571
|
+
native.start = async (request) => {
|
|
572
|
+
startRequests.push(request);
|
|
573
|
+
throw new NativeReviewConsentRequiredError(consent);
|
|
574
|
+
};
|
|
575
|
+
native.answerConsent = async (request) => {
|
|
576
|
+
answers.push(request.answer);
|
|
577
|
+
answerRequests.push(request);
|
|
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" } };
|
|
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 } };
|
|
580
|
+
};
|
|
581
|
+
return { native, answers, startRequests, answerRequests };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function answerConsent(controller: RegisteredTool, binding: unknown, answer: unknown, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
585
|
+
const { details } = await controller.execute("consent-answer", { operation: "answer-consent", input: JSON.stringify({ consentBinding: binding, answer }) }, undefined, undefined, ctx);
|
|
586
|
+
return details as Record<string, unknown>;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function blockedConsent(controller: RegisteredTool, id: string, ctx: ExtensionContext): Promise<Record<string, unknown>> {
|
|
590
|
+
const result = await execStart(controller, id, ctx);
|
|
591
|
+
assert.equal(result.status, "blocked");
|
|
592
|
+
assert.equal(result.outcome, "native-review-consent-required");
|
|
593
|
+
assert.equal(typeof result.consent_binding, "string");
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
test("consent relay returns the identical complete parent-visible envelope with or without UI and never answers internally", async (t) => {
|
|
520
598
|
const cwd = repository(t);
|
|
521
599
|
recordReviewConsentLatch(cwd);
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
600
|
+
for (const ctx of [
|
|
601
|
+
{ cwd, hasUI: true, ui: { select: async () => { throw new Error("internal consent UI must not run"); }, notify: () => {} } } as unknown as ExtensionContext,
|
|
602
|
+
headlessContext(cwd),
|
|
603
|
+
]) {
|
|
604
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
605
|
+
const result = await blockedConsent(runtime(native).controller, "consent-blocked", ctx);
|
|
606
|
+
assert.deepEqual(result.consent, candidateConsent(cwd).raw);
|
|
607
|
+
assert.deepEqual(answers, []);
|
|
608
|
+
assert.equal(result.lineage_created, false);
|
|
609
|
+
}
|
|
527
610
|
});
|
|
528
611
|
|
|
529
|
-
test("consent
|
|
612
|
+
test("explicit consent follow-up grants or declines exactly once", async (t) => {
|
|
530
613
|
const cwd = repository(t);
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
614
|
+
for (const answer of ["granted", "declined"] as const) {
|
|
615
|
+
const { native, answers, startRequests, answerRequests } = relayedConsentNative(cwd);
|
|
616
|
+
const { controller } = runtime(native);
|
|
617
|
+
const blocked = await blockedConsent(controller, `consent-${answer}`, headlessContext(cwd));
|
|
618
|
+
const result = await answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd));
|
|
619
|
+
assert.deepEqual(answers, [answer]);
|
|
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 {
|
|
632
|
+
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
633
|
+
assert.equal(result.lineage_created, false);
|
|
634
|
+
assert.equal(result.actor_binding, undefined);
|
|
635
|
+
}
|
|
636
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, answer, headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
637
|
+
}
|
|
539
638
|
});
|
|
540
639
|
|
|
541
|
-
|
|
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) => {
|
|
542
643
|
const cwd = repository(t);
|
|
543
|
-
const { native } =
|
|
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
|
+
};
|
|
544
648
|
const { controller } = runtime(native);
|
|
545
|
-
|
|
546
|
-
const result = await
|
|
547
|
-
assert.
|
|
548
|
-
assert.equal(
|
|
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, []);
|
|
549
660
|
});
|
|
550
661
|
|
|
551
|
-
test("consent
|
|
662
|
+
test("consent follow-up rejects invalid token, unknown id, changed cwd, and changed target binding", async (t) => {
|
|
552
663
|
const cwd = repository(t);
|
|
553
|
-
const
|
|
664
|
+
const consent = candidateConsent(cwd);
|
|
665
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
666
|
+
native.start = async () => { throw new NativeReviewConsentRequiredError(consent); };
|
|
554
667
|
const { controller } = runtime(native);
|
|
555
|
-
const
|
|
556
|
-
assert.
|
|
557
|
-
assert.
|
|
558
|
-
assert.
|
|
668
|
+
const blocked = await blockedConsent(controller, "consent-invalid", headlessContext(cwd));
|
|
669
|
+
await assert.rejects(() => controller.execute("malformed", { operation: "answer-consent", input: "not-json" }, undefined, undefined, headlessContext(cwd)), /input is not valid JSON/);
|
|
670
|
+
await assert.rejects(() => controller.execute("extra", { operation: "answer-consent", input: JSON.stringify({ consentBinding: blocked.consent_binding, answer: "granted", target: "substitute" }) }, undefined, undefined, headlessContext(cwd)), /exactly consentBinding and answer/);
|
|
671
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "yes", headlessContext(cwd)), /answer must be granted or declined/);
|
|
672
|
+
await assert.rejects(() => answerConsent(controller, "missing", "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
673
|
+
const other = repository(t);
|
|
674
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(other)), /repository binding changed/);
|
|
675
|
+
const originalTarget = consent.targetIdentity;
|
|
676
|
+
(consent as { targetIdentity: string }).targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
677
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
678
|
+
(consent as { targetIdentity: string }).targetIdentity = originalTarget;
|
|
679
|
+
(consent as { projection: string }).projection = "staged";
|
|
680
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
681
|
+
(consent as { projection: string }).projection = "workspace";
|
|
682
|
+
(consent.choices[0] as { invocation: string }).invocation = consent.choices[0].invocation.replace("native-lineage", "changed-lineage");
|
|
683
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /consent envelope binding changed/);
|
|
684
|
+
assert.deepEqual(answers, []);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("ambiguous consent mutation consumes the one-shot binding and requires status instead of blind replay", async (t) => {
|
|
688
|
+
const cwd = repository(t);
|
|
689
|
+
const { native } = relayedConsentNative(cwd);
|
|
690
|
+
let statusCalls = 0;
|
|
691
|
+
const targetStatus = native.targetStatus!;
|
|
692
|
+
native.targetStatus = async (request) => { statusCalls += 1; return await targetStatus(request); };
|
|
693
|
+
native.answerConsent = async () => { throw Object.assign(new Error("ambiguous"), { mutationOutcome: "unknown", nextAction: "review.status" }); };
|
|
694
|
+
const { controller } = runtime(native);
|
|
695
|
+
const blocked = await blockedConsent(controller, "consent-ambiguous", headlessContext(cwd));
|
|
696
|
+
await answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd));
|
|
697
|
+
assert.equal(statusCalls, 2, "ambiguous consent must reconcile through target status");
|
|
698
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
559
699
|
});
|
|
560
700
|
|
|
561
|
-
test("
|
|
701
|
+
test("session shutdown clears pending candidate consent bindings and is idempotent", async (t) => {
|
|
702
|
+
const cwd = repository(t);
|
|
703
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
704
|
+
const { controller, events } = runtime(native);
|
|
705
|
+
const blocked = await blockedConsent(controller, "consent-before-shutdown", headlessContext(cwd));
|
|
706
|
+
const shutdown = events.get("session_shutdown");
|
|
707
|
+
assert.ok(shutdown);
|
|
708
|
+
await shutdown({}, headlessContext(cwd));
|
|
709
|
+
await shutdown({}, headlessContext(cwd));
|
|
710
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
711
|
+
assert.deepEqual(answers, []);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("successful explicit disable clears pending candidate consent binding even after re-enable", async (t) => {
|
|
562
715
|
const cwd = repository(t);
|
|
563
|
-
const { native } =
|
|
716
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
717
|
+
let effective: "on" | "off" = "on";
|
|
718
|
+
native.reviewMode = async (request) => {
|
|
719
|
+
if (request.operation === "disable") effective = "off";
|
|
720
|
+
if (request.operation === "enable") effective = "on";
|
|
721
|
+
return { operation: request.operation, scope: "clone", status: { global: "", cloneLocal: effective === "off" ? "off" : "", effective, source: effective === "off" ? "clone_local" : "default" } };
|
|
722
|
+
};
|
|
723
|
+
const { controller, commands } = runtime(native);
|
|
724
|
+
const blocked = await blockedConsent(controller, "consent-before-disable", headlessContext(cwd));
|
|
725
|
+
const command = commands.get("gentle:review-mode")!;
|
|
726
|
+
await command.handler("disable", headlessContext(cwd));
|
|
727
|
+
await command.handler("enable", headlessContext(cwd));
|
|
728
|
+
await assert.rejects(() => answerConsent(controller, blocked.consent_binding, "granted", headlessContext(cwd)), /unknown, expired, or already consumed/);
|
|
729
|
+
assert.deepEqual(answers, []);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("candidate-scoped decline creates no authority and the next candidate asks again", async (t) => {
|
|
733
|
+
const cwd = repository(t);
|
|
734
|
+
const { native, answers } = relayedConsentNative(cwd);
|
|
564
735
|
const { controller } = runtime(native);
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
736
|
+
for (const id of ["decline-one", "decline-two"]) {
|
|
737
|
+
const blocked = await blockedConsent(controller, id, headlessContext(cwd));
|
|
738
|
+
const result = await answerConsent(controller, blocked.consent_binding, "declined", headlessContext(cwd));
|
|
739
|
+
assert.equal(result.outcome, "consent-declined-this-candidate");
|
|
740
|
+
assert.equal(result.lineage_created, false);
|
|
741
|
+
assert.equal(result.actor_binding, undefined);
|
|
742
|
+
}
|
|
743
|
+
assert.deepEqual(answers, ["declined", "declined"]);
|
|
569
744
|
});
|
|
570
745
|
|
|
571
|
-
test("
|
|
746
|
+
test("low-risk zero-lens START remains silent", async (t) => {
|
|
572
747
|
const cwd = repository(t);
|
|
573
|
-
const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired:
|
|
748
|
+
const { native } = fakeOrganicNative({ riskEvidence: undefined, lensesRequired: false });
|
|
574
749
|
const { controller } = runtime(native);
|
|
575
|
-
let
|
|
576
|
-
const ctx = { cwd, hasUI: true, ui: {
|
|
577
|
-
const result = await execStart(controller, "consent-
|
|
578
|
-
assert.equal(
|
|
579
|
-
assert.ok(result.
|
|
750
|
+
let selected = false;
|
|
751
|
+
const ctx = { cwd, hasUI: true, ui: { select: async () => { selected = true; return undefined; }, notify: () => {} } } as unknown as ExtensionContext;
|
|
752
|
+
const result = await execStart(controller, "consent-low-risk", ctx);
|
|
753
|
+
assert.equal(selected, false);
|
|
754
|
+
assert.ok(result.result);
|
|
580
755
|
});
|
|
581
756
|
|
|
582
757
|
// ---------------------------------------------------------------------------
|