gentle-pi 1.2.0 → 2.1.0
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 +32 -10
- package/assets/agents/review-readability.md +0 -1
- package/assets/agents/review-reliability.md +0 -1
- package/assets/agents/review-resilience.md +0 -1
- package/assets/agents/review-risk.md +0 -1
- package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
- package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
- package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
- package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
- package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
- package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
- package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
- package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
- package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
- package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
- package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
- package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
- package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
- package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
- package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
- package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
- package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
- package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
- package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
- package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
- package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
- package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
- package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
- package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
- package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
- package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
- package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
- package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
- package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
- package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
- package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
- package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
- package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
- package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
- package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
- package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
- package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
- package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
- package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
- package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
- package/docs/native-authority-architecture.md +18 -3
- package/docs/review-integration.md +175 -27
- package/extensions/gentle-ai.ts +647 -58
- package/lib/gentle-ai-binary.ts +65 -18
- package/lib/native-review-cli.ts +824 -56
- package/lib/review-candidate-view.ts +135 -1
- package/lib/review-compact-contract.ts +12 -3
- package/lib/review-consent-latch.ts +51 -0
- package/lib/review-correction-lifecycle.ts +170 -0
- package/lib/review-integration-v2.ts +1567 -0
- package/package.json +15 -11
- package/runtime/gentle-ai-binary.mjs +65 -18
- package/runtime/native-review-cli.mjs +808 -40
- package/runtime/review-integration-v2.mjs +1568 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -1
- package/scripts/gentle-ai-installer.mjs +464 -76
- package/scripts/install-gentle-ai.mjs +7 -3
- package/scripts/test-packed-runner.mjs +3 -3
- package/scripts/verify-package-files.mjs +216 -64
- package/skills/_shared/review-ledger-contract.md +3 -1
- package/skills/release/SKILL.md +38 -11
- package/tests/devbinary/native-review-parity.devtest.ts +348 -0
- package/tests/gentle-ai-binary.test.ts +77 -3
- package/tests/gentle-ai-installer.test.ts +561 -22
- package/tests/gentle-ai.test.ts +2 -2
- package/tests/native-binary-gate.test.ts +145 -0
- package/tests/native-review-capability-contract.test.ts +87 -0
- package/tests/native-review-cli.test.ts +44 -1
- package/tests/native-review-consent.test.ts +128 -0
- package/tests/native-review-parity-runtime.test.ts +85 -29
- package/tests/native-review-parity.test.ts +782 -0
- package/tests/native-risk-evidence-parity.test.ts +91 -0
- package/tests/package-manifest.test.ts +80 -8
- package/tests/review-actor-tool-deny.test.ts +20 -5
- package/tests/review-candidate-view.test.ts +374 -1
- package/tests/review-compact-contract.test.ts +5 -0
- package/tests/review-consent-latch.test.ts +67 -0
- package/tests/review-controller-native-recovery.test.ts +180 -5
- package/tests/review-controller-native-routing.test.ts +632 -18
- package/tests/review-controller-workspace-root.test.ts +26 -6
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-correction-lifecycle.test.ts +174 -0
- package/tests/review-integration-v2.test.ts +548 -0
- package/tests/runtime-harness.mjs +211 -12
- package/tests/support/native-binary-gate.ts +86 -0
- package/tests/verify-package-files.test.ts +119 -0
- package/lib/review-integration-v1.ts +0 -795
- package/runtime/review-integration-v1.mjs +0 -796
- package/tests/native-review-integration-v1.test.ts +0 -102
- package/tests/review-integration-v1.test.ts +0 -530
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { nativeRiskEvidencePhrases } from "../lib/native-review-cli.ts";
|
|
4
|
+
|
|
5
|
+
// gentle-ai's negotiated `start/v2` envelope is a closed schema, so it cannot
|
|
6
|
+
// carry the `risk_evidence` phrases its plain sibling emits. Pi renders them
|
|
7
|
+
// from `risk_reasons`, which start/v2 does carry, and that makes Pi a second
|
|
8
|
+
// surface for a vocabulary whose Go home (internal/cli/review_mode.go) is
|
|
9
|
+
// documented as the single phrasing source precisely so surfaces cannot drift.
|
|
10
|
+
//
|
|
11
|
+
// These cases are field truth, not hand-written expectations: each pair was
|
|
12
|
+
// captured from the real gentle-ai v2.2.0 binary run twice over one candidate,
|
|
13
|
+
// once plain for `risk_evidence` and once negotiated for `risk_reasons`. If a
|
|
14
|
+
// future gentle-ai changes a phrase, this fails instead of Pi quietly showing
|
|
15
|
+
// an operator a sentence the CLI stopped saying.
|
|
16
|
+
const FIELD_CAPTURED_V220 = [
|
|
17
|
+
{
|
|
18
|
+
name: "hot path, auth signal",
|
|
19
|
+
riskLevel: "high",
|
|
20
|
+
riskReasons: [{ code: "hot_path", signal: "auth", path: "auth/login.go" }],
|
|
21
|
+
riskEvidence: ["authentication in auth/login.go"],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "hot path, payments signal",
|
|
25
|
+
riskLevel: "high",
|
|
26
|
+
riskReasons: [{ code: "hot_path", signal: "payments", path: "billing/payments.go" }],
|
|
27
|
+
riskEvidence: ["payments in billing/payments.go"],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "medium risk leads with the consolidated-review reason",
|
|
31
|
+
riskLevel: "medium",
|
|
32
|
+
riskReasons: [{ code: "configuration_change", path: "config.yaml" }],
|
|
33
|
+
riskEvidence: [
|
|
34
|
+
"this change is not purely passive documentation, so it gets one consolidated review.",
|
|
35
|
+
"a configuration change in config.yaml",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "multiple reasons keep native order",
|
|
40
|
+
riskLevel: "high",
|
|
41
|
+
riskReasons: [
|
|
42
|
+
{ code: "executable_mode", signal: "permissions", path: "run.sh", old_mode: "000000", new_mode: "100755" },
|
|
43
|
+
{ code: "process_boundary", signal: "shell_process", path: "run.sh" },
|
|
44
|
+
{ code: "shell_source", signal: "shell_process", path: "run.sh" },
|
|
45
|
+
],
|
|
46
|
+
riskEvidence: [
|
|
47
|
+
"an executable permission change in run.sh",
|
|
48
|
+
"code that starts other processes in run.sh",
|
|
49
|
+
"shell scripting in run.sh",
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
for (const scenario of FIELD_CAPTURED_V220) {
|
|
55
|
+
test(`derived risk evidence matches native v2.2.0: ${scenario.name}`, () => {
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
nativeRiskEvidencePhrases(scenario.riskLevel, scenario.riskReasons as unknown as Record<string, unknown>[]),
|
|
58
|
+
scenario.riskEvidence,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("low risk speaks no evidence at all", () => {
|
|
64
|
+
assert.deepEqual(nativeRiskEvidencePhrases("low", [{ code: "hot_path", signal: "auth", path: "a.go" }]), []);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("an unrecognized reason code renders nothing rather than guessing", () => {
|
|
68
|
+
// Degrading to silence keeps Pi from inventing a phrase for a reason a
|
|
69
|
+
// newer gentle-ai understands and this mirror does not.
|
|
70
|
+
assert.deepEqual(nativeRiskEvidencePhrases("high", [{ code: "reason_from_a_newer_cli", path: "x.go" }]), []);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("an unrecognized hot-path signal still names the path", () => {
|
|
74
|
+
// The Go signal switch has no silent default: it degrades to a generic
|
|
75
|
+
// subject so the operator still learns which file drove the tier.
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
nativeRiskEvidencePhrases("high", [{ code: "hot_path", signal: "signal_from_a_newer_cli", path: "x.go" }]),
|
|
78
|
+
["a sensitive area in x.go"],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an empty file is named before it is described", () => {
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
nativeRiskEvidencePhrases("high", [{ code: "empty_content", path: "blank.txt" }]),
|
|
85
|
+
["blank.txt, an empty file whose type cannot be determined from its content"],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a reason with no path contributes no phrase", () => {
|
|
90
|
+
assert.deepEqual(nativeRiskEvidencePhrases("high", [{ code: "empty_content" }]), []);
|
|
91
|
+
});
|
|
@@ -85,6 +85,10 @@ interface PackageJson {
|
|
|
85
85
|
dependencies?: Record<string, string>;
|
|
86
86
|
bundledDependencies?: string[];
|
|
87
87
|
bundleDependencies?: string[];
|
|
88
|
+
repository?: {
|
|
89
|
+
type?: string;
|
|
90
|
+
url?: string;
|
|
91
|
+
};
|
|
88
92
|
pi?: PackageJsonPiManifest;
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -126,6 +130,67 @@ test("package verification names the native review runtime boundary and packaged
|
|
|
126
130
|
);
|
|
127
131
|
});
|
|
128
132
|
|
|
133
|
+
test("npm publication is bound to the exact package tag and triggering commit", () => {
|
|
134
|
+
const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8");
|
|
135
|
+
const releaseSkill = readFileSync(join(PACKAGE_ROOT, "skills", "release", "SKILL.md"), "utf8");
|
|
136
|
+
const packageJson = readPackageJson();
|
|
137
|
+
const dispatchBlock = workflow.match(
|
|
138
|
+
/^ {2}workflow_dispatch:\n([\s\S]*?)^\npermissions:/m,
|
|
139
|
+
)?.[1];
|
|
140
|
+
assert.ok(dispatchBlock);
|
|
141
|
+
const inputNames = [
|
|
142
|
+
...dispatchBlock.matchAll(/^ {6}([A-Za-z0-9_-]+):$/gm),
|
|
143
|
+
].map((match) => match[1]);
|
|
144
|
+
|
|
145
|
+
assert.match(workflow, /on:\n\s+workflow_dispatch:\s*\n/);
|
|
146
|
+
assert.deepEqual(inputNames, ["tag"], "the trusted workflow must expose exactly one caller input");
|
|
147
|
+
assert.match(workflow, /inputs:\n\s+tag:/, "the trusted main workflow must accept only the release tag");
|
|
148
|
+
assert.match(workflow, /RELEASE_TAG: \$\{\{ inputs\.tag \}\}/);
|
|
149
|
+
assert.doesNotMatch(workflow, /checkout-ref|dist-tag.*inputs|inputs\.(?!tag)/, "the release workflow must not accept checkout or dist-tag inputs");
|
|
150
|
+
assert.match(workflow, /ref: \$\{\{ github\.sha \}\}/, "checkout must use the immutable event SHA");
|
|
151
|
+
assert.match(workflow, /persist-credentials: false/, "the release checkout must not retain GitHub credentials");
|
|
152
|
+
assert.match(workflow, /DEFAULT_BRANCH: \$\{\{ github\.event\.repository\.default_branch \}\}/);
|
|
153
|
+
assert.match(workflow, /\$\{DEFAULT_BRANCH\}" != "main"/);
|
|
154
|
+
assert.match(workflow, /\$\{GITHUB_REF\}" != "refs\/heads\/main"/);
|
|
155
|
+
assert.match(workflow, /\$\{GITHUB_REF_TYPE\}" != "branch"/);
|
|
156
|
+
assert.match(workflow, /Release tag is not exact vSemVer/);
|
|
157
|
+
assert.match(workflow, /git ls-remote origin[\s\S]*"refs\/heads\/main"[\s\S]*"refs\/tags\/\$\{tag\}"/);
|
|
158
|
+
assert.match(workflow, /git fetch --atomic --no-tags origin/);
|
|
159
|
+
assert.match(workflow, /refs\/heads\/main:refs\/remotes\/origin\/release-main/);
|
|
160
|
+
assert.match(workflow, /refs\/tags\/\$\{tag\}:refs\/release-verification\/tag/);
|
|
161
|
+
assert.match(workflow, /git cat-file -t refs\/release-verification\/tag/);
|
|
162
|
+
assert.match(workflow, /git checkout --detach "\$\{tag_commit\}"/);
|
|
163
|
+
assert.match(workflow, /git rev-parse "\$\{GITHUB_SHA\}\^\{commit\}"/);
|
|
164
|
+
assert.match(workflow, /git rev-parse ['"]HEAD\^\{commit\}['"]/);
|
|
165
|
+
assert.match(workflow, /Reverify protected release authority and publish/);
|
|
166
|
+
assert.match(workflow, /Release authority changed after verification/);
|
|
167
|
+
assert.match(workflow, /id-token: write/, "trusted publishing requires OIDC");
|
|
168
|
+
assert.match(workflow, /node-version: "24"/, "trusted publishing must use a supported Node.js version");
|
|
169
|
+
assert.match(workflow, /const minimum = \[11, 5, 1\]/, "trusted publishing must reject npm versions below 11.5.1");
|
|
170
|
+
assert.match(workflow, /packageJson\.repository\?\.type !== expectedRepository\.type/);
|
|
171
|
+
assert.match(workflow, /packageJson\.repository\?\.url !== expectedRepository\.url/);
|
|
172
|
+
assert.deepEqual(
|
|
173
|
+
packageJson.repository,
|
|
174
|
+
{
|
|
175
|
+
type: "git",
|
|
176
|
+
url: "git+https://github.com/Gentleman-Programming/gentle-pi.git",
|
|
177
|
+
},
|
|
178
|
+
"trusted publishing requires the exact case-sensitive npm repository identity",
|
|
179
|
+
);
|
|
180
|
+
assert.match(workflow, /npm publish --provenance --access public/);
|
|
181
|
+
assert.doesNotMatch(workflow, /pnpm publish|--no-git-checks|NODE_AUTH_TOKEN/);
|
|
182
|
+
|
|
183
|
+
assert.match(releaseSkill, /tag="v\$\{version\}"/);
|
|
184
|
+
assert.match(releaseSkill, /release_sha="\$\(git rev-parse 'origin\/main\^\{commit\}'\)"/);
|
|
185
|
+
assert.match(releaseSkill, /git rev-parse "\$\{tag\}\^\{commit\}"/);
|
|
186
|
+
assert.match(releaseSkill, /git fetch --no-tags origin "refs\/tags\/\$\{tag\}"/);
|
|
187
|
+
assert.match(releaseSkill, /gh release create "\$\{tag\}"[\s\S]*--verify-tag/);
|
|
188
|
+
assert.match(releaseSkill, /--ref main/);
|
|
189
|
+
assert.match(releaseSkill, /-f tag="\$\{tag\}"/);
|
|
190
|
+
assert.match(releaseSkill, /trusted OIDC with provenance/);
|
|
191
|
+
assert.doesNotMatch(releaseSkill, /--ref "\$\{tag\}"|-f dist-tag=/);
|
|
192
|
+
});
|
|
193
|
+
|
|
129
194
|
test("publication gate is isolated from graph-v1 authority storage", () => {
|
|
130
195
|
const extension = readFileSync(join(PACKAGE_ROOT, "extensions", "gentle-ai.ts"), "utf8");
|
|
131
196
|
const gate = readFileSync(join(PACKAGE_ROOT, "lib", "review-publication-gate.ts"), "utf8");
|
|
@@ -147,11 +212,12 @@ test("installed commit transaction runner loads JavaScript only and has determin
|
|
|
147
212
|
assert.equal(packageJson.scripts?.["test:packed-runner"], "node scripts/test-packed-runner.mjs");
|
|
148
213
|
assert.match(packageJson.scripts?.prepublishOnly ?? "", /pnpm run test:packed-runner/);
|
|
149
214
|
assert.match(ci, /pnpm run test:packed-runner/);
|
|
150
|
-
assert.
|
|
215
|
+
assert.match(packedRunner, /\["install"[^\]]*"--ignore-scripts=false"/s, "packed install must explicitly enable postinstall");
|
|
216
|
+
assert.doesNotMatch(packedRunner, /\["install"[^\]]*"--ignore-scripts"(?!\=false)/s);
|
|
151
217
|
assert.match(packedRunner, /execFileSync\("where\.exe", \["npm"\]/);
|
|
152
218
|
assert.match(packedRunner, /could not resolve npm-cli\.js without a command shell/);
|
|
153
219
|
assert.doesNotMatch(packedRunner, /ComSpec|cmd\.exe/);
|
|
154
|
-
assert.match(packedRunner, /review", "capabilities", "--contract", "gentle-ai\.review-integration\/
|
|
220
|
+
assert.match(packedRunner, /review", "capabilities", "--contract", "gentle-ai\.review-integration\/v2"/);
|
|
155
221
|
assert.match(runner, /\.\.\/runtime\/git-commit-transaction\.mjs/);
|
|
156
222
|
assert.doesNotMatch(runner, /\.ts["']/);
|
|
157
223
|
assert.doesNotMatch(runner, /experimental-strip-types/);
|
|
@@ -204,14 +270,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
204
270
|
);
|
|
205
271
|
});
|
|
206
272
|
|
|
207
|
-
test("package verification binds the published Gentle AI v2.
|
|
273
|
+
test("package verification binds the published Gentle AI v2.2.2 runtime pin", () => {
|
|
208
274
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
209
275
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
210
276
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
211
277
|
|
|
212
|
-
assert.match(installer, /INSTALLER_VERSION = "2\.
|
|
213
|
-
assert.match(
|
|
214
|
-
assert.match(
|
|
278
|
+
assert.match(installer, /INSTALLER_VERSION = "2\.2\.2"/);
|
|
279
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
280
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:YZcI5dRvoHm82I2CULvgBkB2M3UQQGarYO\/u\/Nt5LSc="/);
|
|
281
|
+
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
282
|
+
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
283
|
+
assert.match(binary, /GENTLE_AI_VERSION = "2\.2\.2"/);
|
|
284
|
+
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
285
|
+
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
286
|
+
assert.match(verifier, /v2\.2\.2/);
|
|
215
287
|
});
|
|
216
288
|
|
|
217
289
|
|
|
@@ -1065,9 +1137,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1065
1137
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1066
1138
|
});
|
|
1067
1139
|
|
|
1068
|
-
test("
|
|
1140
|
+
test("v2.1.0 release package and runtime stop before publication", () => {
|
|
1069
1141
|
const packageJson = readPackageJson();
|
|
1070
|
-
assert.equal(packageJson.version, "1.
|
|
1142
|
+
assert.equal(packageJson.version, "2.1.0", "the release manifest must remain explicitly pinned to v2.1.0");
|
|
1071
1143
|
assert.equal(
|
|
1072
1144
|
packageJson.scripts?.test,
|
|
1073
1145
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
|
|
@@ -109,11 +109,26 @@ test("a tool absent from the allowlist resolves to denied under the deny-all rul
|
|
|
109
109
|
assert.ok(!refuterActive.includes(omitted), `review-refuter must deny omitted tool ${omitted}`);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
// The four lenses deny bash, matching gentle-ai's own Claude-runtime lens
|
|
113
|
+
// agents (`tools: Read, Grep, Glob`) exactly. Contract v2 is explicit: a
|
|
114
|
+
// runtime that cannot enforce a per-command Git shell boundary exposes no
|
|
115
|
+
// shell and reports incomplete inspection rather than claiming backend
|
|
116
|
+
// enforcement. Pi has no per-command scoping layer, so unrestricted bash
|
|
117
|
+
// would let a reviewer read the LIVE worktree instead of the frozen
|
|
118
|
+
// candidate -- and approve bytes that are not the ones shipping. The frozen
|
|
119
|
+
// read-only candidate view Pi already materializes is the sanctioned path;
|
|
120
|
+
// bash was the one thing making it optional.
|
|
121
|
+
//
|
|
122
|
+
// gentle-ai's OpenCode variant does grant bash, but only behind permissions
|
|
123
|
+
// whose broad deny precedes narrow allows for specific Git shapes. That is
|
|
124
|
+
// the other half of the same rule, not an exception to it.
|
|
125
|
+
for (const lens of ["review-risk", "review-resilience", "review-readability", "review-reliability"]) {
|
|
126
|
+
const lensActive = resolveActiveTools(readToolEntries(join(ASSETS_AGENTS_DIR, `${lens}.md`)), registry);
|
|
127
|
+
assert.deepEqual(lensActive, ["read", "grep", "glob"], `${lens} must expose exactly read, grep, and glob`);
|
|
128
|
+
for (const omitted of ["bash", "edit", "write", "mem_save", "codegraph_explore", "mcp__slack__slack_send_message", "subagent_run"]) {
|
|
129
|
+
assert.ok(!lensActive.includes(omitted), `${lens} must deny ${omitted}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
117
132
|
});
|
|
118
133
|
|
|
119
134
|
test("the deny-all rule never enables a tool and never empties the runtime allowlist", () => {
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
CandidateViewError,
|
|
10
10
|
type CandidateGitExecutor,
|
|
11
11
|
createCandidateView,
|
|
12
|
+
deriveChangedPathManifest,
|
|
13
|
+
digestChangedPathManifest,
|
|
12
14
|
injectReviewCandidateView,
|
|
13
15
|
} from "../lib/review-candidate-view.ts";
|
|
14
16
|
|
|
@@ -582,6 +584,49 @@ test("native projections reconstruct symlink, intended-untracked, and gitlink id
|
|
|
582
584
|
assert.deepEqual(projection.gitlinks, { "vendor/dependency": gitlinkCommit });
|
|
583
585
|
});
|
|
584
586
|
|
|
587
|
+
test("native staged projections restore the exact current index over HEAD", (t) => {
|
|
588
|
+
const contributorRoot = repository(t);
|
|
589
|
+
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
590
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "staged candidate\n");
|
|
591
|
+
git(contributorRoot, "add", "tracked.txt");
|
|
592
|
+
const candidateTree = git(contributorRoot, "write-tree");
|
|
593
|
+
const registry = new CandidateViewRegistry();
|
|
594
|
+
|
|
595
|
+
registry.restoreProjectionFromNative("staged-index-projection", contributorRoot, {
|
|
596
|
+
baseTree,
|
|
597
|
+
currentCandidateTree: candidateTree,
|
|
598
|
+
paths: ["tracked.txt"],
|
|
599
|
+
intendedUntracked: [],
|
|
600
|
+
projection: "staged",
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const projection = registry.resolveProjection("staged-index-projection", contributorRoot);
|
|
604
|
+
assert.equal(projection.baseTree, baseTree);
|
|
605
|
+
assert.equal(projection.candidateTree, candidateTree);
|
|
606
|
+
assert.equal(projection.committedOnly, false);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("native staged projections reject a workspace snapshot that differs from the current index", (t) => {
|
|
610
|
+
const contributorRoot = repository(t);
|
|
611
|
+
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
612
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "workspace candidate\n");
|
|
613
|
+
const workspace = new CandidateViewRegistry().create({ contributorRoot });
|
|
614
|
+
try {
|
|
615
|
+
assert.throws(
|
|
616
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("workspace-as-staged", contributorRoot, {
|
|
617
|
+
baseTree,
|
|
618
|
+
currentCandidateTree: workspace.candidateTree,
|
|
619
|
+
paths: ["tracked.txt"],
|
|
620
|
+
intendedUntracked: [],
|
|
621
|
+
projection: "staged",
|
|
622
|
+
}),
|
|
623
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
|
|
624
|
+
);
|
|
625
|
+
} finally {
|
|
626
|
+
workspace.cleanup();
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
|
|
585
630
|
test("native projections recover a committed range base from its frozen tree", (t) => {
|
|
586
631
|
const contributorRoot = repository(t);
|
|
587
632
|
const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
|
|
@@ -596,7 +641,8 @@ test("native projections recover a committed range base from its frozen tree", (
|
|
|
596
641
|
currentCandidateTree: candidateTree,
|
|
597
642
|
paths: ["tracked.txt"],
|
|
598
643
|
intendedUntracked: [],
|
|
599
|
-
|
|
644
|
+
// A committed range: HEAD moved past base with no dirty overlay.
|
|
645
|
+
projection: "staged",
|
|
600
646
|
});
|
|
601
647
|
const projection = registry.resolveProjection("committed-projection", contributorRoot);
|
|
602
648
|
assert.equal(projection.baseCommit, baseCommit);
|
|
@@ -666,3 +712,330 @@ test("live candidate drift blocks dispatch before candidate text can be injected
|
|
|
666
712
|
registry.cleanup(view.token);
|
|
667
713
|
}
|
|
668
714
|
});
|
|
715
|
+
|
|
716
|
+
// --- Phase 3: field-wise changed-path manifest binding -----------------------
|
|
717
|
+
//
|
|
718
|
+
// The sorted-path-set check these tests replace could not see a mode-only or a
|
|
719
|
+
// type change: a file turning executable between START and dispatch produced an
|
|
720
|
+
// identical path set, so the candidate Pi materialized could diverge from the
|
|
721
|
+
// provider's frozen one and the comparison still passed. `--name-status` never
|
|
722
|
+
// carried old_mode, which is why the manifest needs its own derivation.
|
|
723
|
+
|
|
724
|
+
function treeOf(cwd: string): string {
|
|
725
|
+
git(cwd, "add", "-A");
|
|
726
|
+
return git(cwd, "write-tree");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function baseTreeOf(cwd: string): string {
|
|
730
|
+
return git(cwd, "rev-parse", "HEAD^{tree}");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
test("deriveChangedPathManifest reports old and new mode, and flags a mode-only change", (t) => {
|
|
734
|
+
const cwd = repository(t);
|
|
735
|
+
chmodSync(join(cwd, "tracked.txt"), 0o755);
|
|
736
|
+
const candidate = treeOf(cwd);
|
|
737
|
+
|
|
738
|
+
const manifest = deriveChangedPathManifest(cwd, baseTreeOf(cwd), candidate);
|
|
739
|
+
|
|
740
|
+
assert.deepEqual(manifest, [{
|
|
741
|
+
path: "tracked.txt",
|
|
742
|
+
status: "M",
|
|
743
|
+
oldMode: "100644",
|
|
744
|
+
newMode: "100755",
|
|
745
|
+
deleted: false,
|
|
746
|
+
typeChanged: false,
|
|
747
|
+
modeOnly: true,
|
|
748
|
+
}]);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("deriveChangedPathManifest distinguishes content change from mode-only", (t) => {
|
|
752
|
+
const cwd = repository(t);
|
|
753
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
754
|
+
const manifest = deriveChangedPathManifest(cwd, baseTreeOf(cwd), treeOf(cwd));
|
|
755
|
+
|
|
756
|
+
assert.equal(manifest.length, 1);
|
|
757
|
+
assert.equal(manifest[0]?.modeOnly, false);
|
|
758
|
+
assert.equal(manifest[0]?.oldMode, "100644");
|
|
759
|
+
assert.equal(manifest[0]?.newMode, "100644");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("deriveChangedPathManifest marks an added path and a deleted path", (t) => {
|
|
763
|
+
const cwd = repository(t);
|
|
764
|
+
writeFileSync(join(cwd, "added.txt"), "new\n");
|
|
765
|
+
rmSync(join(cwd, "tracked.txt"));
|
|
766
|
+
const manifest = deriveChangedPathManifest(cwd, baseTreeOf(cwd), treeOf(cwd));
|
|
767
|
+
|
|
768
|
+
const byPath = new Map(manifest.map((entry) => [entry.path, entry]));
|
|
769
|
+
assert.equal(byPath.get("added.txt")?.status, "A");
|
|
770
|
+
assert.equal(byPath.get("added.txt")?.deleted, false);
|
|
771
|
+
assert.equal(byPath.get("tracked.txt")?.status, "D");
|
|
772
|
+
assert.equal(byPath.get("tracked.txt")?.deleted, true);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("a mode-only divergence is rejected even though the sorted path set matches", (t) => {
|
|
776
|
+
const cwd = repository(t);
|
|
777
|
+
chmodSync(join(cwd, "tracked.txt"), 0o755);
|
|
778
|
+
const candidate = treeOf(cwd);
|
|
779
|
+
const base = baseTreeOf(cwd);
|
|
780
|
+
|
|
781
|
+
// The provider froze the path as non-executable; Git now reports 100755.
|
|
782
|
+
// The old check compared only sorted paths and would accept this.
|
|
783
|
+
assert.throws(
|
|
784
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-mode-drift", cwd, {
|
|
785
|
+
baseTree: base,
|
|
786
|
+
currentCandidateTree: candidate,
|
|
787
|
+
paths: ["tracked.txt"],
|
|
788
|
+
intendedUntracked: [],
|
|
789
|
+
projection: "workspace",
|
|
790
|
+
manifest: [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
|
|
791
|
+
}),
|
|
792
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-mode-drift",
|
|
793
|
+
);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("a manifest whose path set differs from Git content is rejected as path-set drift", (t) => {
|
|
797
|
+
const cwd = repository(t);
|
|
798
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
799
|
+
const candidate = treeOf(cwd);
|
|
800
|
+
const base = baseTreeOf(cwd);
|
|
801
|
+
|
|
802
|
+
assert.throws(
|
|
803
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-path-drift", cwd, {
|
|
804
|
+
baseTree: base,
|
|
805
|
+
currentCandidateTree: candidate,
|
|
806
|
+
paths: ["tracked.txt", "ghost.txt"],
|
|
807
|
+
intendedUntracked: [],
|
|
808
|
+
projection: "workspace",
|
|
809
|
+
manifest: [
|
|
810
|
+
{ path: "ghost.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false },
|
|
811
|
+
{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false },
|
|
812
|
+
],
|
|
813
|
+
}),
|
|
814
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-path-set-drift",
|
|
815
|
+
);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("a manifest whose status disagrees with Git is rejected as status drift", (t) => {
|
|
819
|
+
const cwd = repository(t);
|
|
820
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
821
|
+
const candidate = treeOf(cwd);
|
|
822
|
+
const base = baseTreeOf(cwd);
|
|
823
|
+
|
|
824
|
+
assert.throws(
|
|
825
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-status-drift", cwd, {
|
|
826
|
+
baseTree: base,
|
|
827
|
+
currentCandidateTree: candidate,
|
|
828
|
+
paths: ["tracked.txt"],
|
|
829
|
+
intendedUntracked: [],
|
|
830
|
+
projection: "workspace",
|
|
831
|
+
manifest: [{ path: "tracked.txt", status: "A", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
|
|
832
|
+
}),
|
|
833
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-status-drift",
|
|
834
|
+
);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("intended-untracked outside the manifest path set is rejected as a subset violation", (t) => {
|
|
838
|
+
const cwd = repository(t);
|
|
839
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
840
|
+
const candidate = treeOf(cwd);
|
|
841
|
+
const base = baseTreeOf(cwd);
|
|
842
|
+
|
|
843
|
+
// intended_untracked is provider-only knowledge no Git command reproduces,
|
|
844
|
+
// so it is checked structurally as a subset rather than compared field-wise.
|
|
845
|
+
// That is a deliberate, documented deviation from the spec's field list.
|
|
846
|
+
assert.throws(
|
|
847
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-untracked-drift", cwd, {
|
|
848
|
+
baseTree: base,
|
|
849
|
+
currentCandidateTree: candidate,
|
|
850
|
+
paths: ["tracked.txt"],
|
|
851
|
+
intendedUntracked: ["not-in-manifest.txt"],
|
|
852
|
+
projection: "workspace",
|
|
853
|
+
manifest: [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
|
|
854
|
+
}),
|
|
855
|
+
(error: unknown) => error instanceof CandidateViewError,
|
|
856
|
+
);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("a manifest that disagrees with the descriptor's own paths is rejected as input divergence", (t) => {
|
|
860
|
+
const cwd = repository(t);
|
|
861
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
862
|
+
const candidate = treeOf(cwd);
|
|
863
|
+
const base = baseTreeOf(cwd);
|
|
864
|
+
|
|
865
|
+
assert.throws(
|
|
866
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-input-drift", cwd, {
|
|
867
|
+
baseTree: base,
|
|
868
|
+
currentCandidateTree: candidate,
|
|
869
|
+
paths: ["tracked.txt"],
|
|
870
|
+
intendedUntracked: [],
|
|
871
|
+
projection: "workspace",
|
|
872
|
+
manifest: [{ path: "other.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
|
|
873
|
+
}),
|
|
874
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-input-divergence",
|
|
875
|
+
);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("a manifest matching Git field-wise restores the projection", (t) => {
|
|
879
|
+
const cwd = repository(t);
|
|
880
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
881
|
+
const candidate = treeOf(cwd);
|
|
882
|
+
const base = baseTreeOf(cwd);
|
|
883
|
+
|
|
884
|
+
assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-manifest-ok", cwd, {
|
|
885
|
+
baseTree: base,
|
|
886
|
+
currentCandidateTree: candidate,
|
|
887
|
+
paths: ["tracked.txt"],
|
|
888
|
+
intendedUntracked: [],
|
|
889
|
+
projection: "workspace",
|
|
890
|
+
manifest: [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }],
|
|
891
|
+
}));
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("a descriptor without a manifest keeps the legacy path-set behavior", (t) => {
|
|
895
|
+
const cwd = repository(t);
|
|
896
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
897
|
+
const candidate = treeOf(cwd);
|
|
898
|
+
const base = baseTreeOf(cwd);
|
|
899
|
+
|
|
900
|
+
// Phase 3 is additive: the manifest is optional, and its absence must not
|
|
901
|
+
// change how existing callers behave.
|
|
902
|
+
assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-no-manifest", cwd, {
|
|
903
|
+
baseTree: base,
|
|
904
|
+
currentCandidateTree: candidate,
|
|
905
|
+
paths: ["tracked.txt"],
|
|
906
|
+
intendedUntracked: [],
|
|
907
|
+
projection: "workspace",
|
|
908
|
+
}));
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// --- 3.1 completion: manifest-subject-drift ---------------------------------
|
|
912
|
+
//
|
|
913
|
+
// The v2 artifact-subject schema (contracts/review-integration/v2/schemas/
|
|
914
|
+
// artifact-subject.schema.json) requires `changed_path_manifest_sha256` on
|
|
915
|
+
// every subject. That is the provider's own claim about what the manifest it
|
|
916
|
+
// handed the dispatch digests to. A manifest that does not digest to that
|
|
917
|
+
// claim is a self-consistency failure Pi can catch without touching Git at
|
|
918
|
+
// all: the descriptor disagrees with itself before any content comparison.
|
|
919
|
+
|
|
920
|
+
test("a manifest whose digest disagrees with the subject's claim is rejected as subject drift", (t) => {
|
|
921
|
+
const cwd = repository(t);
|
|
922
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
923
|
+
const candidate = treeOf(cwd);
|
|
924
|
+
const base = baseTreeOf(cwd);
|
|
925
|
+
const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
|
|
926
|
+
|
|
927
|
+
assert.throws(
|
|
928
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-subject-drift", cwd, {
|
|
929
|
+
baseTree: base,
|
|
930
|
+
currentCandidateTree: candidate,
|
|
931
|
+
paths: ["tracked.txt"],
|
|
932
|
+
intendedUntracked: [],
|
|
933
|
+
projection: "workspace",
|
|
934
|
+
manifest,
|
|
935
|
+
// A subject claim that cannot possibly match any real digest.
|
|
936
|
+
manifestSha256: `sha256:${"0".repeat(64)}`,
|
|
937
|
+
}),
|
|
938
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "manifest-subject-drift",
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("a manifest digest matching the subject's claim restores the projection", (t) => {
|
|
943
|
+
const cwd = repository(t);
|
|
944
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
945
|
+
const candidate = treeOf(cwd);
|
|
946
|
+
const base = baseTreeOf(cwd);
|
|
947
|
+
const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
|
|
948
|
+
|
|
949
|
+
assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-subject-ok", cwd, {
|
|
950
|
+
baseTree: base,
|
|
951
|
+
currentCandidateTree: candidate,
|
|
952
|
+
paths: ["tracked.txt"],
|
|
953
|
+
intendedUntracked: [],
|
|
954
|
+
projection: "workspace",
|
|
955
|
+
manifest,
|
|
956
|
+
manifestSha256: digestChangedPathManifest(manifest),
|
|
957
|
+
}));
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("a descriptor without a subject digest keeps validating the manifest without a subject-drift check", (t) => {
|
|
961
|
+
const cwd = repository(t);
|
|
962
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
963
|
+
const candidate = treeOf(cwd);
|
|
964
|
+
const base = baseTreeOf(cwd);
|
|
965
|
+
const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
|
|
966
|
+
|
|
967
|
+
// `manifestSha256` is additive, mirroring `manifest` itself: its absence
|
|
968
|
+
// must not change existing manifest-bound behavior.
|
|
969
|
+
assert.doesNotThrow(() => new CandidateViewRegistry().restoreProjectionFromNative("review-no-subject-digest", cwd, {
|
|
970
|
+
baseTree: base,
|
|
971
|
+
currentCandidateTree: candidate,
|
|
972
|
+
paths: ["tracked.txt"],
|
|
973
|
+
intendedUntracked: [],
|
|
974
|
+
projection: "workspace",
|
|
975
|
+
manifest,
|
|
976
|
+
}));
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// --- 3.4 threat matrix: Git repository selection ----------------------------
|
|
980
|
+
//
|
|
981
|
+
// A manifest binding must never widen the existing root-scoping guarantee:
|
|
982
|
+
// a projection frozen against one contributor root stays rejected when
|
|
983
|
+
// resolved against a different one, exactly as it was before manifests
|
|
984
|
+
// existed.
|
|
985
|
+
|
|
986
|
+
test("a manifest-bound projection is rejected when resolved against a different contributor root", (t) => {
|
|
987
|
+
const cwd = repository(t);
|
|
988
|
+
writeFileSync(join(cwd, "tracked.txt"), "changed\n");
|
|
989
|
+
const candidate = treeOf(cwd);
|
|
990
|
+
const base = baseTreeOf(cwd);
|
|
991
|
+
const manifest = [{ path: "tracked.txt", status: "M", oldMode: "100644", newMode: "100644", deleted: false, typeChanged: false, modeOnly: false }];
|
|
992
|
+
const registry = new CandidateViewRegistry();
|
|
993
|
+
registry.restoreProjectionFromNative("review-root-scope", cwd, {
|
|
994
|
+
baseTree: base,
|
|
995
|
+
currentCandidateTree: candidate,
|
|
996
|
+
paths: ["tracked.txt"],
|
|
997
|
+
intendedUntracked: [],
|
|
998
|
+
projection: "workspace",
|
|
999
|
+
manifest,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const otherRoot = repository(t);
|
|
1003
|
+
assert.throws(
|
|
1004
|
+
() => registry.resolveProjection("review-root-scope", otherRoot),
|
|
1005
|
+
(error: unknown) => error instanceof CandidateViewError && /different contributor root/.test(error.message),
|
|
1006
|
+
);
|
|
1007
|
+
// The root guard still resolves correctly against the true root.
|
|
1008
|
+
assert.equal(registry.resolveProjection("review-root-scope", cwd).candidateTree, candidate);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// --- 3.5 threat matrix: Commit state ----------------------------------------
|
|
1012
|
+
//
|
|
1013
|
+
// `projection` on the descriptor claims "staged" (a committed range) or
|
|
1014
|
+
// "workspace" (dirty-inclusive). `restoreProjectionFromNative` independently
|
|
1015
|
+
// derives `committedOnly` from the descriptor's own base/candidate
|
|
1016
|
+
// relationship to HEAD; the claimed label must agree with that derivation, or
|
|
1017
|
+
// the request is rejected rather than silently trusting — or silently
|
|
1018
|
+
// overriding — a mislabeled projection kind.
|
|
1019
|
+
|
|
1020
|
+
test("a projection labeled workspace but derived as a genuinely committed range is rejected as a projection-kind mismatch", (t) => {
|
|
1021
|
+
const cwd = repository(t);
|
|
1022
|
+
const baseTree = baseTreeOf(cwd);
|
|
1023
|
+
writeFileSync(join(cwd, "tracked.txt"), "committed candidate\n");
|
|
1024
|
+
git(cwd, "add", "tracked.txt");
|
|
1025
|
+
git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "candidate");
|
|
1026
|
+
const candidateTree = git(cwd, "rev-parse", "HEAD^{tree}");
|
|
1027
|
+
|
|
1028
|
+
assert.throws(
|
|
1029
|
+
() => new CandidateViewRegistry().restoreProjectionFromNative("review-projection-kind-drift", cwd, {
|
|
1030
|
+
baseTree,
|
|
1031
|
+
currentCandidateTree: candidateTree,
|
|
1032
|
+
paths: ["tracked.txt"],
|
|
1033
|
+
intendedUntracked: [],
|
|
1034
|
+
// Git facts derive committedOnly=true (HEAD moved past base with no
|
|
1035
|
+
// dirty overlay), but the descriptor mislabels it as a workspace
|
|
1036
|
+
// snapshot.
|
|
1037
|
+
projection: "workspace",
|
|
1038
|
+
}),
|
|
1039
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
|
|
1040
|
+
);
|
|
1041
|
+
});
|
|
@@ -121,6 +121,11 @@ test("compact finalize parser rejects malformed nested findings and incomplete f
|
|
|
121
121
|
test("native finalize preserves arbitrary non-empty evidence text and binds refuter batches", () => {
|
|
122
122
|
const evidence = " \tleading evidence\nterminal newlines\n\n";
|
|
123
123
|
assert.equal(parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_passed: true }).final_evidence, evidence);
|
|
124
|
+
for (const outcome of ["passed", "verification_failed", "procedural_tooling_failed"] as const) {
|
|
125
|
+
assert.equal(parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_outcome: outcome }).final_verification_outcome, outcome);
|
|
126
|
+
}
|
|
127
|
+
assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_outcome: "failed" }), CompactReviewContractError);
|
|
128
|
+
assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: evidence, final_verification_passed: true, final_verification_outcome: "passed" }), CompactReviewContractError);
|
|
124
129
|
assert.throws(() => parseNativeCompactFinalizeInput({ cwd: "/repo", final_evidence: "", final_verification_passed: true }), CompactReviewContractError);
|
|
125
130
|
|
|
126
131
|
const proof = "differential-test:candidate still fails";
|