gentle-pi 2.1.2 → 2.3.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 +108 -79
- package/assets/agents/gentle-ai-worker.md +7 -3
- package/assets/agents/jd-fix-agent.md +1 -1
- package/assets/agents/jd-judge-a.md +3 -1
- package/assets/agents/jd-judge-b.md +3 -1
- package/assets/agents/review-readability.md +4 -3
- package/assets/agents/review-reliability.md +4 -3
- package/assets/agents/review-resilience.md +4 -3
- package/assets/agents/review-risk.md +4 -3
- package/assets/agents/sdd-apply.md +11 -4
- package/assets/agents/sdd-archive.md +6 -1
- package/assets/agents/sdd-design.md +6 -1
- package/assets/agents/sdd-explore.md +6 -2
- package/assets/agents/sdd-init.md +10 -2
- package/assets/agents/sdd-onboard.md +6 -1
- package/assets/agents/sdd-proposal.md +8 -1
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +10 -5
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +8 -4
- package/assets/agents/sdd-verify.md +27 -2
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +2 -2
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +2 -2
- package/assets/orchestrator-delegation.md +145 -160
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +25 -48
- package/assets/sdd-orchestrator-workflow.md +163 -25
- package/assets/support/sdd-status-contract.md +24 -6
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
- package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
- package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
- package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
- package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
- package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
- package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
- package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
- package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
- package/docs/native-authority-architecture.md +14 -16
- package/docs/review-integration.md +27 -353
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +2710 -3211
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +21 -12
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +676 -781
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +836 -118
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +578 -0
- package/lib/review-integration-v2.ts +1254 -221
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +230 -67
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -79
- package/runtime/gentle-ai-binary.mjs +174 -3
- package/runtime/native-review-cli.mjs +610 -715
- package/runtime/review-integration-v2.mjs +1223 -190
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +85 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +20 -9
- package/scripts/verify-package-files.mjs +119 -43
- package/skills/_shared/review-ledger-contract.md +11 -19
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +9 -5
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/background-subagents.test.ts +771 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +16 -0
- package/tests/delegated-key-learnings-contract.test.ts +240 -0
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
- package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
- package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
- package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
- package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
- package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
- package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +159 -0
- package/tests/gentle-ai-installer.test.ts +138 -48
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +601 -0
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +75 -2
- package/tests/native-review-cli.test.ts +588 -908
- package/tests/native-review-consent.test.ts +283 -38
- package/tests/native-review-parity-runtime.test.ts +108 -350
- package/tests/native-review-parity.test.ts +684 -732
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +158 -89
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +128 -100
- package/tests/provider-contract-bundle.test.ts +385 -0
- package/tests/provider-contract-mirror.test.ts +206 -0
- package/tests/provider-defect-handoff.test.ts +252 -0
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +898 -12
- package/tests/review-compact-contract.test.ts +29 -122
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +366 -857
- package/tests/review-controller-native-routing.test.ts +1253 -4199
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +236 -70
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +134 -0
- package/tests/review-dispatch-hydration-gap.test.ts +145 -0
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +352 -0
- package/tests/review-host-relay.test.ts +754 -0
- package/tests/review-integration-v2-forward.test.ts +654 -0
- package/tests/review-integration-v2.test.ts +317 -129
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +106 -60
- package/tests/review-recovered-lineage-routing.test.ts +199 -0
- package/tests/review-relay-transport-agent.test.ts +322 -0
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +573 -180
- package/tests/sdd-agent-tools.test.ts +53 -32
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +6 -8
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- package/lib/git-commit-transaction.ts +0 -801
- package/lib/native-review-remediation.ts +0 -49
- package/lib/review-compact.ts +0 -947
- package/lib/review-refuter-adapter.ts +0 -129
- package/lib/review-runtime-contract.ts +0 -68
- package/prompts/gcl.md +0 -54
- package/prompts/gis.md +0 -25
- package/prompts/gpr.md +0 -41
- package/prompts/gwr.md +0 -31
- package/runtime/git-commit-transaction.mjs +0 -802
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -302
- package/tests/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
6
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
7
|
import { tmpdir } from "node:os";
|
|
5
8
|
import { join } from "node:path";
|
|
6
9
|
import test from "node:test";
|
|
10
|
+
import { gzipSync } from "node:zlib";
|
|
7
11
|
import {
|
|
8
12
|
CandidateViewRegistry,
|
|
9
13
|
CandidateViewError,
|
|
10
14
|
type CandidateGitExecutor,
|
|
11
15
|
createCandidateView,
|
|
16
|
+
decodeCandidateContextManifest,
|
|
12
17
|
deriveChangedPathManifest,
|
|
13
18
|
digestChangedPathManifest,
|
|
14
19
|
injectReviewCandidateView,
|
|
20
|
+
readCandidateContextManifestPage,
|
|
15
21
|
} from "../lib/review-candidate-view.ts";
|
|
16
22
|
|
|
17
23
|
function git(cwd: string, ...arguments_: string[]): string {
|
|
@@ -28,14 +34,153 @@ function repository(t: test.TestContext): string {
|
|
|
28
34
|
return cwd;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
function compactCandidateContextManifest(task: string): { encoded: string; sha256: string } {
|
|
38
|
+
const match = /Frozen changed scope manifest \(gzip\+base64url\): `([A-Za-z0-9_-]+)`\.\nFrozen changed scope manifest SHA-256: `([0-9a-f]{64})`\./.exec(task);
|
|
39
|
+
assert.ok(match, "expected a compact candidate context manifest");
|
|
40
|
+
return { encoded: match[1]!, sha256: match[2]! };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("candidate view Git commands classify bounded timeouts and block materialization before worktree execution", (t) => {
|
|
44
|
+
const calls: Array<{ arguments: readonly string[]; timeout: number | undefined; maxBuffer: number | undefined }> = [];
|
|
45
|
+
const executor: CandidateGitExecutor = (_file, arguments_, options) => { calls.push({ arguments: arguments_, timeout: options.timeout, maxBuffer: options.maxBuffer }); throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT", killed: true }); };
|
|
46
|
+
let failure: unknown;
|
|
47
|
+
try {
|
|
48
|
+
new CandidateViewRegistry(executor).create({ contributorRoot: repository(t) });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
failure = error;
|
|
51
|
+
}
|
|
52
|
+
assert.ok(failure instanceof CandidateViewError);
|
|
53
|
+
assert.equal(failure.reason, "candidate-view-timeout");
|
|
54
|
+
assert.deepEqual((failure as CandidateViewError & { diagnostics?: unknown }).diagnostics, {
|
|
55
|
+
phase: "candidate-view",
|
|
56
|
+
category: "timeout",
|
|
57
|
+
git_subcommand: "rev-parse",
|
|
58
|
+
timeout_ms: 10_000,
|
|
59
|
+
max_buffer_bytes: 64 * 1024 * 1024,
|
|
60
|
+
message: "candidate-view Git command rev-parse timed out after 10000ms; inspect the candidate state before any new START",
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(calls, [{ arguments: ["rev-parse", "--git-common-dir"], timeout: 10_000, maxBuffer: 64 * 1024 * 1024 }]);
|
|
36
63
|
assert.equal(calls.some((call) => call.arguments[0] === "worktree"), false);
|
|
37
64
|
});
|
|
38
65
|
|
|
66
|
+
test("candidate-view Git timeouts use a bounded strict-decimal override with safe fallback", (t) => {
|
|
67
|
+
const withTimeout = <T>(value: string | undefined, callback: () => T): T => {
|
|
68
|
+
const previous = process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS;
|
|
69
|
+
try {
|
|
70
|
+
if (value === undefined) delete process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS;
|
|
71
|
+
else process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS = value;
|
|
72
|
+
return callback();
|
|
73
|
+
} finally {
|
|
74
|
+
if (previous === undefined) delete process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS;
|
|
75
|
+
else process.env.GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS = previous;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
for (const [name, value, expected] of [
|
|
79
|
+
["default", undefined, 10_000],
|
|
80
|
+
["valid override", "45000", 45_000],
|
|
81
|
+
...(["", "0", "0010", "-1", "1.5", "not-a-number", "120001", "Infinity"] as const).map((value) => [`invalid override ${JSON.stringify(value)}`, value, 10_000] as const),
|
|
82
|
+
] as const) {
|
|
83
|
+
const timeouts: Array<number | undefined> = [];
|
|
84
|
+
let failure: unknown;
|
|
85
|
+
withTimeout(value, () => {
|
|
86
|
+
try {
|
|
87
|
+
new CandidateViewRegistry((_file, _arguments, options) => {
|
|
88
|
+
timeouts.push(options.timeout);
|
|
89
|
+
throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
|
|
90
|
+
}).create({ contributorRoot: repository(t) });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
failure = error;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
assert.ok(failure instanceof CandidateViewError, name);
|
|
96
|
+
assert.equal(timeouts[0], expected, name);
|
|
97
|
+
assert.equal((failure as CandidateViewError & { diagnostics?: { timeout_ms?: unknown } }).diagnostics?.timeout_ms, expected, name);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("explicit base resolution preserves structured for-each-ref output-limit diagnostics", (t) => {
|
|
102
|
+
const contributorRoot = repository(t);
|
|
103
|
+
const executor: CandidateGitExecutor = (file, arguments_, options) => {
|
|
104
|
+
if (arguments_[0] === "for-each-ref") throw Object.assign(new Error("sensitive base-reference output"), { code: "ENOBUFS", killed: true, stderr: Buffer.from("sensitive base-reference output") });
|
|
105
|
+
return execFileSync(file, arguments_, options);
|
|
106
|
+
};
|
|
107
|
+
let failure: unknown;
|
|
108
|
+
try {
|
|
109
|
+
new CandidateViewRegistry(executor).create({ contributorRoot, baseRef: "refs/heads/main", committedOnly: true });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
failure = error;
|
|
112
|
+
}
|
|
113
|
+
assert.ok(failure instanceof CandidateViewError);
|
|
114
|
+
assert.equal(failure.reason, "candidate-view-output-limit");
|
|
115
|
+
assert.deepEqual(failure.diagnostics, {
|
|
116
|
+
phase: "candidate-view",
|
|
117
|
+
category: "output-limit",
|
|
118
|
+
git_subcommand: "for-each-ref",
|
|
119
|
+
timeout_ms: 10_000,
|
|
120
|
+
max_buffer_bytes: 64 * 1024 * 1024,
|
|
121
|
+
message: "candidate-view Git command for-each-ref exceeded the 67108864-byte output limit; inspect the candidate state before any new START",
|
|
122
|
+
});
|
|
123
|
+
assert.doesNotMatch(failure.message, /sensitive base-reference output/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("every synchronous candidate-view Git command receives the explicit 64 MiB output limit", (t) => {
|
|
127
|
+
const calls: Array<{ arguments: readonly string[]; maxBuffer: number | undefined }> = [];
|
|
128
|
+
const executor: CandidateGitExecutor = (file, arguments_, options) => {
|
|
129
|
+
calls.push({ arguments: arguments_, maxBuffer: options.maxBuffer });
|
|
130
|
+
return execFileSync(file, arguments_, options);
|
|
131
|
+
};
|
|
132
|
+
const view = new CandidateViewRegistry(executor).create({ contributorRoot: repository(t) });
|
|
133
|
+
try {
|
|
134
|
+
assert.ok(calls.length > 1);
|
|
135
|
+
assert.ok(calls.every((call) => call.maxBuffer === 64 * 1024 * 1024), JSON.stringify(calls));
|
|
136
|
+
} finally {
|
|
137
|
+
view.cleanup();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("candidate view classifies both Node synchronous-process output-limit errors ahead of killed state without exposing process output", (t) => {
|
|
142
|
+
const attemptedOutputBytes = 64 * 1024 * 1024 + 1;
|
|
143
|
+
for (const code of ["ENOBUFS", "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"]) {
|
|
144
|
+
let failure: unknown;
|
|
145
|
+
try {
|
|
146
|
+
new CandidateViewRegistry((_file, _arguments, options) => {
|
|
147
|
+
assert.ok((options.maxBuffer ?? 0) < attemptedOutputBytes, code);
|
|
148
|
+
throw Object.assign(new Error("sensitive stderr and candidate bytes"), { code, killed: true, stderr: Buffer.from("sensitive stderr and candidate bytes") });
|
|
149
|
+
}).create({ contributorRoot: repository(t) });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
failure = error;
|
|
152
|
+
}
|
|
153
|
+
assert.ok(failure instanceof CandidateViewError, code);
|
|
154
|
+
assert.equal(failure.reason, "candidate-view-output-limit", code);
|
|
155
|
+
assert.deepEqual((failure as CandidateViewError & { diagnostics?: unknown }).diagnostics, {
|
|
156
|
+
phase: "candidate-view",
|
|
157
|
+
category: "output-limit",
|
|
158
|
+
git_subcommand: "rev-parse",
|
|
159
|
+
timeout_ms: 10_000,
|
|
160
|
+
max_buffer_bytes: 64 * 1024 * 1024,
|
|
161
|
+
message: "candidate-view Git command rev-parse exceeded the 67108864-byte output limit; inspect the candidate state before any new START",
|
|
162
|
+
}, code);
|
|
163
|
+
assert.doesNotMatch(failure.message, /sensitive stderr|candidate bytes/, code);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("candidate Git accepts deterministic output above 1 MiB up to its explicit bound", () => {
|
|
168
|
+
const row = `:100644 100644 ${"a".repeat(40)} ${"b".repeat(40)} M\0tracked.txt\0`;
|
|
169
|
+
const copies = Math.ceil((1024 * 1024 + 1) / Buffer.byteLength(row));
|
|
170
|
+
const output = Buffer.from(row.repeat(copies));
|
|
171
|
+
assert.ok(output.length > 1024 * 1024);
|
|
172
|
+
assert.ok(output.length < 64 * 1024 * 1024);
|
|
173
|
+
const calls: Array<{ maxBuffer: number | undefined }> = [];
|
|
174
|
+
const manifest = deriveChangedPathManifest("/candidate", "a".repeat(40), "b".repeat(40), (_file, arguments_, options) => {
|
|
175
|
+
assert.deepEqual(arguments_.slice(0, 2), ["diff", "--raw"]);
|
|
176
|
+
calls.push({ maxBuffer: options.maxBuffer });
|
|
177
|
+
return output;
|
|
178
|
+
});
|
|
179
|
+
assert.equal(manifest.length, copies);
|
|
180
|
+
assert.ok(manifest.every((entry) => entry.path === "tracked.txt" && entry.status === "M"));
|
|
181
|
+
assert.deepEqual(calls, [{ maxBuffer: 64 * 1024 * 1024 }]);
|
|
182
|
+
});
|
|
183
|
+
|
|
39
184
|
test("committed-only candidate views scope an explicit base to committed changes and exclude dirty worktree files", (t) => {
|
|
40
185
|
const contributorRoot = repository(t);
|
|
41
186
|
const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
|
|
@@ -170,6 +315,27 @@ function commitFileAfterBase(cwd: string): void {
|
|
|
170
315
|
git(cwd, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "committed after base");
|
|
171
316
|
}
|
|
172
317
|
|
|
318
|
+
// An unborn repository has a symbolic HEAD pointing at a branch with no
|
|
319
|
+
// commits yet; its review base is Git's repository-native empty tree, not a
|
|
320
|
+
// missing or malformed commit. `mktree` with empty input derives that empty
|
|
321
|
+
// tree object-format-aware (sha1 or sha256) without hardcoding the SHA-1 id.
|
|
322
|
+
function emptyTreeOf(cwd: string): string {
|
|
323
|
+
return execFileSync("git", ["-C", cwd, "mktree"], { encoding: "utf8", input: "" }).trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function unbornRepository(t: test.TestContext, stage = true): string {
|
|
327
|
+
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-unborn-"));
|
|
328
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
329
|
+
git(cwd, "init", "-b", "main");
|
|
330
|
+
git(cwd, "config", "user.name", "Candidate Test");
|
|
331
|
+
git(cwd, "config", "user.email", "candidate@example.invalid");
|
|
332
|
+
if (stage) {
|
|
333
|
+
writeFileSync(join(cwd, "staged.txt"), "first staged\n");
|
|
334
|
+
git(cwd, "add", "staged.txt");
|
|
335
|
+
}
|
|
336
|
+
return cwd;
|
|
337
|
+
}
|
|
338
|
+
|
|
173
339
|
test("candidate view materializes exact tracked and initially-untracked content while contributor diverges", (t) => {
|
|
174
340
|
const contributorRoot = repository(t);
|
|
175
341
|
writeFileSync(join(contributorRoot, "tracked.txt"), "frozen tracked\n");
|
|
@@ -187,6 +353,66 @@ test("candidate view materializes exact tracked and initially-untracked content
|
|
|
187
353
|
view.cleanup();
|
|
188
354
|
});
|
|
189
355
|
|
|
356
|
+
test("candidate view preserves staged additions with explicit intended-untracked selection in its private index", (t) => {
|
|
357
|
+
const contributorRoot = repository(t);
|
|
358
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
|
|
359
|
+
writeFileSync(join(contributorRoot, "staged-addition.txt"), "staged addition\n");
|
|
360
|
+
git(contributorRoot, "add", "staged-addition.txt");
|
|
361
|
+
git(contributorRoot, "update-index", "--split-index");
|
|
362
|
+
writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
|
|
363
|
+
writeFileSync(join(contributorRoot, "excluded.txt"), "excluded\n");
|
|
364
|
+
const indexBefore = readFileSync(join(contributorRoot, ".git", "index"));
|
|
365
|
+
const all = createCandidateView({ contributorRoot });
|
|
366
|
+
const excluded = createCandidateView({ contributorRoot, intendedUntracked: [] });
|
|
367
|
+
const selected = createCandidateView({ contributorRoot, intendedUntracked: ["selected.txt"] });
|
|
368
|
+
try {
|
|
369
|
+
assert.deepEqual(all.paths, ["excluded.txt", "selected.txt", "staged-addition.txt", "tracked.txt"]);
|
|
370
|
+
assert.deepEqual(excluded.paths, ["staged-addition.txt", "tracked.txt"]);
|
|
371
|
+
assert.deepEqual(selected.paths, ["selected.txt", "staged-addition.txt", "tracked.txt"]);
|
|
372
|
+
assert.equal(readFileSync(join(selected.root, "staged-addition.txt"), "utf8"), "staged addition\n");
|
|
373
|
+
assert.equal(lstatSync(join(selected.root, "excluded.txt"), { throwIfNoEntry: false }), undefined);
|
|
374
|
+
assert.deepEqual(readFileSync(join(contributorRoot, ".git", "index")), indexBefore);
|
|
375
|
+
} finally {
|
|
376
|
+
all.cleanup();
|
|
377
|
+
excluded.cleanup();
|
|
378
|
+
selected.cleanup();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("candidate view skips a shared index that disappears during stat or copy", (t) => {
|
|
383
|
+
const contributorRoot = repository(t);
|
|
384
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
|
|
385
|
+
writeFileSync(join(contributorRoot, "staged-addition.txt"), "staged addition\n");
|
|
386
|
+
git(contributorRoot, "add", "staged-addition.txt");
|
|
387
|
+
git(contributorRoot, "update-index", "--split-index");
|
|
388
|
+
const sharedIndexName = readdirSync(join(contributorRoot, ".git")).find((name) => /^sharedindex\.[0-9a-f]+$/.test(name));
|
|
389
|
+
assert.ok(sharedIndexName, "split index must create a shared index fixture");
|
|
390
|
+
const sharedIndexPath = join(contributorRoot, ".git", sharedIndexName);
|
|
391
|
+
for (const phase of ["stat", "copy"] as const) {
|
|
392
|
+
const originalLstatSync = fs.lstatSync;
|
|
393
|
+
const originalCopyFileSync = fs.copyFileSync;
|
|
394
|
+
fs.lstatSync = ((path: string | Buffer, options?: Parameters<typeof lstatSync>[1]) => {
|
|
395
|
+
if (phase === "stat" && path === sharedIndexPath) throw Object.assign(new Error("shared index disappeared"), { code: "ENOENT" });
|
|
396
|
+
return originalLstatSync(path, options);
|
|
397
|
+
}) as typeof fs.lstatSync;
|
|
398
|
+
fs.copyFileSync = ((source: string | Buffer, destination: string | Buffer) => {
|
|
399
|
+
if (phase === "copy" && source === sharedIndexPath) throw Object.assign(new Error("shared index disappeared"), { code: "ENOENT" });
|
|
400
|
+
return originalCopyFileSync(source, destination);
|
|
401
|
+
}) as typeof fs.copyFileSync;
|
|
402
|
+
syncBuiltinESMExports();
|
|
403
|
+
let view: ReturnType<typeof createCandidateView> | undefined;
|
|
404
|
+
try {
|
|
405
|
+
view = createCandidateView({ contributorRoot, intendedUntracked: [] });
|
|
406
|
+
assert.equal(view.paths.includes("staged-addition.txt"), true, phase);
|
|
407
|
+
} finally {
|
|
408
|
+
fs.lstatSync = originalLstatSync;
|
|
409
|
+
fs.copyFileSync = originalCopyFileSync;
|
|
410
|
+
syncBuiltinESMExports();
|
|
411
|
+
view?.cleanup();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
190
416
|
test("candidate view recursively protects nested content and worktree metadata, and rejects injected untracked entries", (t) => {
|
|
191
417
|
const contributorRoot = repository(t);
|
|
192
418
|
mkdirSync(join(contributorRoot, "nested", "deeper"), { recursive: true });
|
|
@@ -231,6 +457,56 @@ test("candidate view registry rejects unsafe, moved, writable, stale, and unsele
|
|
|
231
457
|
registry.cleanup(view.token);
|
|
232
458
|
});
|
|
233
459
|
|
|
460
|
+
test("candidate registry isolates replay, projection, current, and cleanup state by target root plus lineage", (t) => {
|
|
461
|
+
const rootA = repository(t);
|
|
462
|
+
const rootB = repository(t);
|
|
463
|
+
writeFileSync(join(rootA, "tracked.txt"), "candidate A\n");
|
|
464
|
+
writeFileSync(join(rootB, "tracked.txt"), "candidate B\n");
|
|
465
|
+
const registry = new CandidateViewRegistry();
|
|
466
|
+
t.after(() => registry.cleanupAll());
|
|
467
|
+
const viewA = registry.createOrReuse({ contributorRoot: rootA, replayKey: "same-replay" });
|
|
468
|
+
const viewB = registry.createOrReuse({ contributorRoot: rootB, replayKey: "same-replay" });
|
|
469
|
+
assert.notEqual(viewA.token, viewB.token);
|
|
470
|
+
registry.bindCurrent({ token: viewA.token, lineageId: "same-lineage", selectedLenses: ["review-reliability"] });
|
|
471
|
+
registry.bindCurrent({ token: viewB.token, lineageId: "same-lineage", selectedLenses: ["review-reliability"] });
|
|
472
|
+
assert.equal(registry.resolveForLens("same-lineage", "review-reliability", rootA).root, viewA.root);
|
|
473
|
+
assert.equal(registry.resolveForLens("same-lineage", "review-reliability", rootB).root, viewB.root);
|
|
474
|
+
assert.equal(registry.resolveForFinalize("same-lineage", rootA).root, viewA.root);
|
|
475
|
+
assert.equal(registry.resolveForFinalize("same-lineage", rootB).root, viewB.root);
|
|
476
|
+
writeFileSync(join(rootA, "tracked.txt"), "corrected A\n");
|
|
477
|
+
writeFileSync(join(rootB, "tracked.txt"), "corrected B\n");
|
|
478
|
+
const correctedA = registry.createCorrected("same-lineage", rootA, "same-correction-replay");
|
|
479
|
+
const correctedB = registry.createCorrected("same-lineage", rootB, "same-correction-replay");
|
|
480
|
+
assert.notEqual(correctedA.token, correctedB.token);
|
|
481
|
+
registry.promoteCorrected("same-lineage", correctedA.token, rootA);
|
|
482
|
+
registry.promoteCorrected("same-lineage", correctedB.token, rootB);
|
|
483
|
+
assert.equal(registry.resolveForFinalize("same-lineage", rootA).root, correctedA.root);
|
|
484
|
+
assert.equal(registry.resolveForFinalize("same-lineage", rootB).root, correctedB.root);
|
|
485
|
+
assert.throws(() => registry.resolveForLens("same-lineage", "review-reliability"), /workspaceRoot/);
|
|
486
|
+
assert.throws(() => registry.resolveWorkspaceRoot("same-lineage"), /workspaceRoot/);
|
|
487
|
+
registry.cleanupTerminal("same-lineage", "approved", rootB);
|
|
488
|
+
assert.equal(registry.resolveWorkspaceRoot("same-lineage"), realpathSync(rootA));
|
|
489
|
+
assert.equal(registry.resolveForFinalize("same-lineage", rootA).root, correctedA.root);
|
|
490
|
+
registry.cleanupTerminal("same-lineage", "approved", rootA);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("candidate registry rejects a lineage target whose authorized symlink was replaced", (t) => {
|
|
494
|
+
const root = repository(t);
|
|
495
|
+
const replacement = repository(t);
|
|
496
|
+
const alias = join(tmpdir(), `gentle-pi-candidate-alias-${process.pid}-${Date.now()}`);
|
|
497
|
+
t.after(() => rmSync(alias, { recursive: true, force: true }));
|
|
498
|
+
symlinkSync(root, alias, "dir");
|
|
499
|
+
const registry = new CandidateViewRegistry();
|
|
500
|
+
t.after(() => registry.cleanupAll());
|
|
501
|
+
const view = registry.create({ contributorRoot: root });
|
|
502
|
+
registry.bindCurrent({ token: view.token, lineageId: "symlink-lineage", selectedLenses: ["review-reliability"] });
|
|
503
|
+
assert.doesNotThrow(() => registry.assertWorkspaceRoot("symlink-lineage", alias));
|
|
504
|
+
rmSync(alias, { recursive: true, force: true });
|
|
505
|
+
symlinkSync(replacement, alias, "dir");
|
|
506
|
+
assert.throws(() => registry.assertWorkspaceRoot("symlink-lineage", alias), /workspaceRoot|bound to/);
|
|
507
|
+
registry.cleanupTerminal("symlink-lineage", "approved", root);
|
|
508
|
+
});
|
|
509
|
+
|
|
234
510
|
test("review subagent dispatch rejects missing candidate views and uses the explicitly current overlapping lens", (t) => {
|
|
235
511
|
const missing = new CandidateViewRegistry();
|
|
236
512
|
assert.throws(
|
|
@@ -270,6 +546,16 @@ test("candidate view cleanup is confined and idempotent", (t) => {
|
|
|
270
546
|
assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
|
|
271
547
|
});
|
|
272
548
|
|
|
549
|
+
test("candidate cleanup removes a readonly root when Git reports success without deleting it", (t) => {
|
|
550
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) =>
|
|
551
|
+
arguments_[0] === "worktree" && arguments_[1] === "remove" ? "" : execFileSync(file, arguments_, options));
|
|
552
|
+
const view = registry.create({ contributorRoot: repository(t) });
|
|
553
|
+
assert.equal(lstatSync(view.root).mode & 0o222, 0);
|
|
554
|
+
view.cleanup();
|
|
555
|
+
view.cleanup();
|
|
556
|
+
assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
|
|
557
|
+
});
|
|
558
|
+
|
|
273
559
|
test("corrected views stay within frozen scope and replace projections only when promoted", (t) => {
|
|
274
560
|
const contributorRoot = repository(t);
|
|
275
561
|
writeFileSync(join(contributorRoot, "tracked.txt"), "reviewed\n");
|
|
@@ -354,13 +640,16 @@ test("candidate view derives deletion, rename, executable, and symlink scope fro
|
|
|
354
640
|
const registry = new CandidateViewRegistry();
|
|
355
641
|
const view = registry.create({ contributorRoot });
|
|
356
642
|
try {
|
|
357
|
-
|
|
358
|
-
|
|
643
|
+
// The rename source (tracked.txt) is frozen as a deletion next to its
|
|
644
|
+
// destination: native STATUS projects the rename as both paths
|
|
645
|
+
// (gentle-pi#518), and the reviewer scope carries the same identity.
|
|
646
|
+
assert.deepEqual(view.paths, ["deleted.txt", "linked.sh", "renamed.txt", "script.sh", "tracked.txt"]);
|
|
647
|
+
assert.deepEqual(view.deletedPaths, ["deleted.txt", "tracked.txt"]);
|
|
359
648
|
assert.deepEqual(view.modes, { "linked.sh": "120000", "renamed.txt": "100644", "script.sh": "100755" });
|
|
360
649
|
registry.bind({ token: view.token, lineageId: "scope-kinds", selectedLenses: ["review-risk"] });
|
|
361
650
|
const dispatch = { agent: "review-risk", task: "review", mode: "task" };
|
|
362
651
|
injectReviewCandidateView(dispatch, registry);
|
|
363
|
-
assert.match(dispatch.task, /Frozen changed scope by mode: .*"deleted":\["deleted\.txt"\]/);
|
|
652
|
+
assert.match(dispatch.task, /Frozen changed scope by mode: .*"deleted":\["deleted\.txt","tracked\.txt"\]/);
|
|
364
653
|
assert.doesNotMatch(dispatch.task, /Frozen paths:|Frozen modes:/);
|
|
365
654
|
view.verify();
|
|
366
655
|
} finally {
|
|
@@ -368,6 +657,40 @@ test("candidate view derives deletion, rename, executable, and symlink scope fro
|
|
|
368
657
|
}
|
|
369
658
|
});
|
|
370
659
|
|
|
660
|
+
// gentle-pi#518: native STATUS projects every path whose state changed between
|
|
661
|
+
// the frozen trees (Go diffs with --no-renames), so a staged exact rename is
|
|
662
|
+
// its source deletion plus its destination addition. The candidate view must
|
|
663
|
+
// project the same identity or ordinary START is rejected before native
|
|
664
|
+
// admission with candidate-target-projection-drift.
|
|
665
|
+
test("candidate view projects a staged exact rename as its source and destination, matching the native no-renames projection", (t) => {
|
|
666
|
+
const contributorRoot = repository(t);
|
|
667
|
+
mkdirSync(join(contributorRoot, "active"));
|
|
668
|
+
writeFileSync(join(contributorRoot, "active", "document.md"), "line one\nline two\n");
|
|
669
|
+
git(contributorRoot, "add", "active/document.md");
|
|
670
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "rename base");
|
|
671
|
+
mkdirSync(join(contributorRoot, "archive"));
|
|
672
|
+
git(contributorRoot, "mv", "active/document.md", "archive/document.md");
|
|
673
|
+
writeFileSync(join(contributorRoot, "report.md"), "report\n");
|
|
674
|
+
git(contributorRoot, "add", "-A");
|
|
675
|
+
const view = createCandidateView({ contributorRoot });
|
|
676
|
+
try {
|
|
677
|
+
const nativeProjection = git(contributorRoot, "diff-tree", "--no-commit-id", "--name-only", "-r", "-z", "--no-renames", view.baseTree, view.candidateTree)
|
|
678
|
+
.split("\0").filter((path) => path.length > 0).sort();
|
|
679
|
+
assert.deepEqual(nativeProjection, ["active/document.md", "archive/document.md", "report.md"]);
|
|
680
|
+
assert.deepEqual(view.paths, nativeProjection);
|
|
681
|
+
assert.deepEqual(view.deletedPaths, ["active/document.md"]);
|
|
682
|
+
assert.deepEqual(view.modes, { "archive/document.md": "100644", "report.md": "100644" });
|
|
683
|
+
const manifest = deriveChangedPathManifest(contributorRoot, view.baseTree, view.candidateTree);
|
|
684
|
+
assert.deepEqual(manifest.map((entry) => [entry.path, entry.status, entry.deleted]), [
|
|
685
|
+
["active/document.md", "D", true],
|
|
686
|
+
["archive/document.md", "A", false],
|
|
687
|
+
["report.md", "A", false],
|
|
688
|
+
]);
|
|
689
|
+
} finally {
|
|
690
|
+
view.cleanup();
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
371
694
|
test("candidate view verifies unchanged tree entries even when they are absent from changed scope", (t) => {
|
|
372
695
|
const contributorRoot = repository(t);
|
|
373
696
|
writeFileSync(join(contributorRoot, "unchanged.txt"), "base\n");
|
|
@@ -388,15 +711,121 @@ test("candidate view verifies unchanged tree entries even when they are absent f
|
|
|
388
711
|
}
|
|
389
712
|
});
|
|
390
713
|
|
|
391
|
-
test("candidate view
|
|
714
|
+
test("candidate view compacts an oversized non-ASCII scope losslessly and deterministically", (t) => {
|
|
715
|
+
const contributorRoot = repository(t);
|
|
716
|
+
writeFileSync(join(contributorRoot, "deleted.txt"), "delete me\n");
|
|
717
|
+
git(contributorRoot, "add", "deleted.txt");
|
|
718
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "deletion base");
|
|
719
|
+
const baseCommit = git(contributorRoot, "rev-parse", "HEAD");
|
|
720
|
+
const unicodePaths = Array.from({ length: 18 }, (_, index) => `changed-${String(index).padStart(2, "0")}-${"界".repeat(70)}.txt`);
|
|
721
|
+
for (const path of unicodePaths) writeFileSync(join(contributorRoot, path), "candidate\n");
|
|
722
|
+
const gitlinkCommit = "0123456789abcdef0123456789abcdef01234567";
|
|
723
|
+
git(contributorRoot, "add", ...unicodePaths);
|
|
724
|
+
git(contributorRoot, "update-index", "--add", "--cacheinfo", `160000,${gitlinkCommit},vendor/dependency`);
|
|
725
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "large scope");
|
|
726
|
+
rmSync(join(contributorRoot, "deleted.txt"));
|
|
727
|
+
git(contributorRoot, "add", "-u", "--", "deleted.txt");
|
|
728
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "delete scope path");
|
|
729
|
+
const registry = new CandidateViewRegistry();
|
|
730
|
+
const view = registry.create({ contributorRoot, baseRef: baseCommit, committedOnly: true });
|
|
731
|
+
try {
|
|
732
|
+
registry.bind({ token: view.token, lineageId: "oversized-scope", selectedLenses: ["review-risk"] });
|
|
733
|
+
const first = { agent: "review-risk", task: "review", mode: "task" };
|
|
734
|
+
const second = { agent: "review-risk", task: "review", mode: "task" };
|
|
735
|
+
injectReviewCandidateView(first, registry);
|
|
736
|
+
injectReviewCandidateView(second, registry);
|
|
737
|
+
const compact = compactCandidateContextManifest(first.task);
|
|
738
|
+
assert.deepEqual(compact, compactCandidateContextManifest(second.task));
|
|
739
|
+
const decoded = decodeCandidateContextManifest(compact.encoded, compact.sha256);
|
|
740
|
+
assert.deepEqual(decoded.manifest, {
|
|
741
|
+
version: 1,
|
|
742
|
+
scopeByMode: { "100644": unicodePaths, "160000": ["vendor/dependency"], deleted: ["deleted.txt"] },
|
|
743
|
+
gitlinks: { "vendor/dependency": gitlinkCommit },
|
|
744
|
+
});
|
|
745
|
+
assert.equal(decoded.sha256, createHash("sha256").update(decoded.bytes).digest("hex"));
|
|
746
|
+
assert.ok(JSON.stringify(decoded.manifest).length < 4_096, "UTF-16 code units alone must not decide the dispatch bound");
|
|
747
|
+
assert.ok(decoded.bytes.length > 4_096, "the manifest must retain its full non-ASCII UTF-8 byte sequence");
|
|
748
|
+
const actorEntries: Array<{ path: string; mode: string; gitlinkObjectId?: string }> = [];
|
|
749
|
+
let cursor: number | undefined = 0;
|
|
750
|
+
while (cursor !== undefined) {
|
|
751
|
+
const page = readCandidateContextManifestPage(compact.encoded, compact.sha256, cursor);
|
|
752
|
+
actorEntries.push(...page.entries);
|
|
753
|
+
assert.ok(Buffer.byteLength(JSON.stringify(page), "utf8") <= 16 * 1024);
|
|
754
|
+
cursor = page.nextCursor;
|
|
755
|
+
}
|
|
756
|
+
assert.deepEqual(actorEntries, [
|
|
757
|
+
...unicodePaths.map((path) => ({ path, mode: "100644" })),
|
|
758
|
+
{ path: "vendor/dependency", mode: "160000", gitlinkObjectId: gitlinkCommit },
|
|
759
|
+
{ path: "deleted.txt", mode: "deleted" },
|
|
760
|
+
]);
|
|
761
|
+
assert.ok(Buffer.byteLength(first.task, "utf8") <= Buffer.byteLength("review", "utf8") + 4_096);
|
|
762
|
+
assert.doesNotMatch(first.task, /Frozen changed scope by mode:/);
|
|
763
|
+
assert.match(first.task, /Call `gentle_review_scope`/);
|
|
764
|
+
assert.throws(() => decodeCandidateContextManifest(compact.encoded, `${compact.sha256.slice(0, -1)}0`), /integrity/);
|
|
765
|
+
assert.throws(() => readCandidateContextManifestPage(compact.encoded, compact.sha256, actorEntries.length + 1), /cursor/);
|
|
766
|
+
const nonCanonicalBytes = Buffer.from(JSON.stringify({ gitlinks: decoded.manifest.gitlinks, scopeByMode: decoded.manifest.scopeByMode, version: 1 }), "utf8");
|
|
767
|
+
assert.throws(
|
|
768
|
+
() => decodeCandidateContextManifest(gzipSync(nonCanonicalBytes, { mtime: 0 }).toString("base64url"), createHash("sha256").update(nonCanonicalBytes).digest("hex")),
|
|
769
|
+
/canonical/,
|
|
770
|
+
);
|
|
771
|
+
} finally {
|
|
772
|
+
registry.cleanup(view.token);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test("candidate context manifest decoder accepts canonical numeric-looking gitlink paths", () => {
|
|
777
|
+
const gitlinks = {
|
|
778
|
+
"10": "0123456789abcdef0123456789abcdef01234567",
|
|
779
|
+
"2": "89abcdef0123456789abcdef0123456789abcdef",
|
|
780
|
+
};
|
|
781
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
782
|
+
version: 1,
|
|
783
|
+
scopeByMode: { "160000": ["10", "2"] },
|
|
784
|
+
gitlinks,
|
|
785
|
+
}), "utf8");
|
|
786
|
+
const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url");
|
|
787
|
+
assert.deepEqual(decodeCandidateContextManifest(encoded, createHash("sha256").update(bytes).digest("hex")).manifest, {
|
|
788
|
+
version: 1,
|
|
789
|
+
scopeByMode: { "160000": ["10", "2"] },
|
|
790
|
+
gitlinks,
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("candidate context manifest decoder rejects noncanonical nonnumeric gitlink ordering", () => {
|
|
795
|
+
const gitlinks = {
|
|
796
|
+
zeta: "0123456789abcdef0123456789abcdef01234567",
|
|
797
|
+
alpha: "89abcdef0123456789abcdef0123456789abcdef",
|
|
798
|
+
};
|
|
799
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
800
|
+
version: 1,
|
|
801
|
+
scopeByMode: { "160000": ["alpha", "zeta"] },
|
|
802
|
+
gitlinks,
|
|
803
|
+
}), "utf8");
|
|
804
|
+
assert.throws(
|
|
805
|
+
() => decodeCandidateContextManifest(gzipSync(bytes, { mtime: 0 }).toString("base64url"), createHash("sha256").update(bytes).digest("hex")),
|
|
806
|
+
/canonical/,
|
|
807
|
+
);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("candidate context manifest decoder rejects noncanonical gzip transport for verified bytes", () => {
|
|
811
|
+
const bytes = Buffer.from(JSON.stringify({ version: 1, scopeByMode: { "100644": ["file.ts"] }, gitlinks: {} }), "utf8");
|
|
812
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
813
|
+
const canonical = gzipSync(bytes, { mtime: 0 });
|
|
814
|
+
const noncanonical = Buffer.from(canonical);
|
|
815
|
+
noncanonical[4] = (noncanonical[4]! + 1) & 0xff;
|
|
816
|
+
assert.throws(() => decodeCandidateContextManifest(noncanonical.toString("base64url"), sha256), /canonical/);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("candidate view fails closed when an incompressible compact scope exceeds 4096 UTF-8 bytes", (t) => {
|
|
392
820
|
const contributorRoot = repository(t);
|
|
393
821
|
for (let index = 0; index < 80; index += 1) {
|
|
394
|
-
|
|
822
|
+
const entropy = createHash("sha512").update(`candidate-scope-${index}`).digest("hex");
|
|
823
|
+
writeFileSync(join(contributorRoot, `changed-${String(index).padStart(3, "0")}-${entropy}.txt`), "candidate\n");
|
|
395
824
|
}
|
|
396
825
|
const registry = new CandidateViewRegistry();
|
|
397
826
|
const view = registry.create({ contributorRoot });
|
|
398
827
|
try {
|
|
399
|
-
registry.bind({ token: view.token, lineageId: "
|
|
828
|
+
registry.bind({ token: view.token, lineageId: "incompressible-scope", selectedLenses: ["review-risk"] });
|
|
400
829
|
assert.throws(
|
|
401
830
|
() => injectReviewCandidateView({ agent: "review-risk", task: "review", mode: "task" }, registry),
|
|
402
831
|
/candidate view context exceeds the bounded dispatch contract/,
|
|
@@ -562,6 +991,30 @@ test("candidate views freeze gitlinks as immutable metadata without materializin
|
|
|
562
991
|
}
|
|
563
992
|
});
|
|
564
993
|
|
|
994
|
+
test("native projection reconstruction retains its selected untracked subset", (t) => {
|
|
995
|
+
const contributorRoot = repository(t);
|
|
996
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
|
|
997
|
+
writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
|
|
998
|
+
writeFileSync(join(contributorRoot, "excluded.txt"), "excluded\n");
|
|
999
|
+
const source = new CandidateViewRegistry();
|
|
1000
|
+
const selected = source.create({ contributorRoot, intendedUntracked: ["selected.txt"] });
|
|
1001
|
+
const restored = new CandidateViewRegistry();
|
|
1002
|
+
try {
|
|
1003
|
+
const view = restored.restoreForFinalizeFromNative("selected-native", contributorRoot, {
|
|
1004
|
+
baseTree: selected.baseTree,
|
|
1005
|
+
currentCandidateTree: selected.candidateTree,
|
|
1006
|
+
paths: selected.paths,
|
|
1007
|
+
intendedUntracked: ["selected.txt"],
|
|
1008
|
+
projection: "workspace",
|
|
1009
|
+
});
|
|
1010
|
+
assert.deepEqual(view.paths, ["selected.txt", "tracked.txt"]);
|
|
1011
|
+
assert.equal(lstatSync(join(view.root, "excluded.txt"), { throwIfNoEntry: false }), undefined);
|
|
1012
|
+
view.cleanup();
|
|
1013
|
+
} finally {
|
|
1014
|
+
selected.cleanup();
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
565
1018
|
test("native projections reconstruct symlink, intended-untracked, and gitlink identity from Git objects", (t) => {
|
|
566
1019
|
const contributorRoot = repository(t);
|
|
567
1020
|
const baseTree = git(contributorRoot, "rev-parse", "HEAD^{tree}");
|
|
@@ -690,6 +1143,229 @@ test("fresh registries restore only one exact authoritative reviewing candidate
|
|
|
690
1143
|
}
|
|
691
1144
|
});
|
|
692
1145
|
|
|
1146
|
+
interface CandidateViewRegistryInternals {
|
|
1147
|
+
records: Map<string, unknown>;
|
|
1148
|
+
bindRecord: (token: string, lineageId: string, selectedLenses: readonly unknown[]) => unknown;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function bindingFailureRegistry(t: test.TestContext): { registry: CandidateViewRegistry; materializedRoot: () => string | undefined; internals: CandidateViewRegistryInternals } {
|
|
1152
|
+
let root: string | undefined;
|
|
1153
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
1154
|
+
if (arguments_[0] === "worktree" && arguments_[1] === "add") root = arguments_[arguments_.indexOf("--no-checkout") + 1];
|
|
1155
|
+
return execFileSync(file, arguments_, options);
|
|
1156
|
+
});
|
|
1157
|
+
t.after(() => registry.cleanupAll());
|
|
1158
|
+
return { registry, materializedRoot: () => root, internals: registry as unknown as CandidateViewRegistryInternals };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
test("failed authoritative restores remove the inserted registry record and readonly worktree", (t) => {
|
|
1162
|
+
const contributorRoot = repository(t);
|
|
1163
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "reviewing\n");
|
|
1164
|
+
const source = new CandidateViewRegistry();
|
|
1165
|
+
const frozen = source.create({ contributorRoot });
|
|
1166
|
+
const state = {
|
|
1167
|
+
lineageId: "restore-bind-failure",
|
|
1168
|
+
contributorRoot,
|
|
1169
|
+
baseCommit: frozen.baseCommit,
|
|
1170
|
+
baseTree: frozen.baseTree,
|
|
1171
|
+
candidateTree: frozen.candidateTree,
|
|
1172
|
+
paths: frozen.paths,
|
|
1173
|
+
modes: frozen.modes,
|
|
1174
|
+
deletedPaths: frozen.deletedPaths,
|
|
1175
|
+
selectedLenses: ["review-reliability"],
|
|
1176
|
+
};
|
|
1177
|
+
const { registry, materializedRoot, internals } = bindingFailureRegistry(t);
|
|
1178
|
+
const originalBindRecord = internals.bindRecord;
|
|
1179
|
+
internals.bindRecord = (token) => {
|
|
1180
|
+
assert.equal(internals.records.has(token), true, "the failure must occur after insertion");
|
|
1181
|
+
throw new CandidateViewError("test-only bind failure");
|
|
1182
|
+
};
|
|
1183
|
+
try {
|
|
1184
|
+
assert.throws(
|
|
1185
|
+
() => registry.restoreCurrentFromAuthoritativeReviewingStates(contributorRoot, [state]),
|
|
1186
|
+
CandidateViewError,
|
|
1187
|
+
);
|
|
1188
|
+
} finally {
|
|
1189
|
+
internals.bindRecord = originalBindRecord;
|
|
1190
|
+
source.cleanup(frozen.token);
|
|
1191
|
+
}
|
|
1192
|
+
assert.equal(internals.records.size, 0);
|
|
1193
|
+
assert.ok(materializedRoot(), "the restore must have created a candidate worktree");
|
|
1194
|
+
assert.equal(existsSync(materializedRoot()!), false);
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
test("failed finalize restores remove the inserted registry record and readonly worktree", (t) => {
|
|
1198
|
+
const contributorRoot = repository(t);
|
|
1199
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
1200
|
+
const source = new CandidateViewRegistry();
|
|
1201
|
+
const frozen = source.create({ contributorRoot });
|
|
1202
|
+
const { registry, materializedRoot, internals } = bindingFailureRegistry(t);
|
|
1203
|
+
const originalBindRecord = internals.bindRecord;
|
|
1204
|
+
internals.bindRecord = (token) => {
|
|
1205
|
+
assert.equal(internals.records.has(token), true, "the failure must occur after insertion");
|
|
1206
|
+
throw new CandidateViewError("test-only bind failure");
|
|
1207
|
+
};
|
|
1208
|
+
try {
|
|
1209
|
+
assert.throws(
|
|
1210
|
+
() => registry.restoreForFinalizeFromNative("finalize-bind-failure", contributorRoot, {
|
|
1211
|
+
baseTree: frozen.baseTree,
|
|
1212
|
+
currentCandidateTree: frozen.candidateTree,
|
|
1213
|
+
paths: frozen.paths,
|
|
1214
|
+
intendedUntracked: [],
|
|
1215
|
+
projection: "workspace",
|
|
1216
|
+
}),
|
|
1217
|
+
CandidateViewError,
|
|
1218
|
+
);
|
|
1219
|
+
} finally {
|
|
1220
|
+
internals.bindRecord = originalBindRecord;
|
|
1221
|
+
source.cleanup(frozen.token);
|
|
1222
|
+
}
|
|
1223
|
+
assert.equal(internals.records.size, 0);
|
|
1224
|
+
assert.ok(materializedRoot(), "the restore must have created a candidate worktree");
|
|
1225
|
+
assert.equal(existsSync(materializedRoot()!), false);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
function materializationFailureRegistry(t: test.TestContext, failureAt: number): { registry: CandidateViewRegistry; roots: () => readonly string[]; removalAttempts: (root: string) => number; internals: CandidateViewRegistryInternals } {
|
|
1229
|
+
const roots: string[] = [];
|
|
1230
|
+
const removalAttempts = new Map<string, number>();
|
|
1231
|
+
let materializations = 0;
|
|
1232
|
+
let failPending = true;
|
|
1233
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
1234
|
+
if (arguments_[0] === "worktree" && arguments_[1] === "add") {
|
|
1235
|
+
const root = arguments_[arguments_.indexOf("--no-checkout") + 1];
|
|
1236
|
+
assert.equal(typeof root, "string", "worktree add must name its candidate root");
|
|
1237
|
+
roots.push(root);
|
|
1238
|
+
materializations += 1;
|
|
1239
|
+
}
|
|
1240
|
+
if (arguments_[0] === "worktree" && arguments_[1] === "remove") {
|
|
1241
|
+
const root = arguments_[arguments_.indexOf("--force") + 1];
|
|
1242
|
+
assert.equal(typeof root, "string", "worktree remove must name its candidate root");
|
|
1243
|
+
removalAttempts.set(root, (removalAttempts.get(root) ?? 0) + 1);
|
|
1244
|
+
}
|
|
1245
|
+
if (failPending && materializations === failureAt && options.cwd === roots.at(-1) && arguments_[0] === "read-tree") {
|
|
1246
|
+
failPending = false;
|
|
1247
|
+
throw Object.assign(new Error("test-only materialization failure"), { status: 1 });
|
|
1248
|
+
}
|
|
1249
|
+
return execFileSync(file, arguments_, options);
|
|
1250
|
+
});
|
|
1251
|
+
t.after(() => registry.cleanupAll());
|
|
1252
|
+
return { registry, roots: () => roots, removalAttempts: (root) => removalAttempts.get(root) ?? 0, internals: registry as unknown as CandidateViewRegistryInternals };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
test("finalize restore removes a registered projection when its first materialization fails and retries cleanly", (t) => {
|
|
1256
|
+
const contributorRoot = repository(t);
|
|
1257
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
1258
|
+
const source = new CandidateViewRegistry();
|
|
1259
|
+
const frozen = source.create({ contributorRoot });
|
|
1260
|
+
const lineageId = "finalize-materialization-failure";
|
|
1261
|
+
const descriptor = {
|
|
1262
|
+
baseTree: frozen.baseTree,
|
|
1263
|
+
currentCandidateTree: frozen.candidateTree,
|
|
1264
|
+
paths: frozen.paths,
|
|
1265
|
+
intendedUntracked: [],
|
|
1266
|
+
projection: "workspace" as const,
|
|
1267
|
+
};
|
|
1268
|
+
const baselineWorktrees = git(contributorRoot, "worktree", "list", "--porcelain");
|
|
1269
|
+
const { registry, roots, internals } = materializationFailureRegistry(t, 1);
|
|
1270
|
+
try {
|
|
1271
|
+
assert.throws(() => registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor), CandidateViewError);
|
|
1272
|
+
assert.equal(registry.hasProjection(lineageId, contributorRoot), false);
|
|
1273
|
+
assert.equal(internals.records.size, 0);
|
|
1274
|
+
assert.equal(git(contributorRoot, "worktree", "list", "--porcelain"), baselineWorktrees);
|
|
1275
|
+
assert.equal(existsSync(roots()[0]!), false);
|
|
1276
|
+
|
|
1277
|
+
const restored = registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
|
|
1278
|
+
assert.equal(registry.resolveForFinalize(lineageId, contributorRoot).token, restored.token);
|
|
1279
|
+
registry.cleanup(restored.token);
|
|
1280
|
+
} finally {
|
|
1281
|
+
source.cleanup(frozen.token);
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
test("finalize restore does not re-remove its first fallback record when the empty-untracked retry materialization fails", (t) => {
|
|
1286
|
+
const contributorRoot = repository(t);
|
|
1287
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
1288
|
+
writeFileSync(join(contributorRoot, "excluded.txt"), "excluded\n");
|
|
1289
|
+
const source = new CandidateViewRegistry();
|
|
1290
|
+
const frozen = source.create({ contributorRoot, intendedUntracked: [] });
|
|
1291
|
+
const lineageId = "finalize-fallback-materialization-failure";
|
|
1292
|
+
const descriptor = {
|
|
1293
|
+
baseTree: frozen.baseTree,
|
|
1294
|
+
currentCandidateTree: frozen.candidateTree,
|
|
1295
|
+
paths: frozen.paths,
|
|
1296
|
+
intendedUntracked: [],
|
|
1297
|
+
projection: "workspace" as const,
|
|
1298
|
+
};
|
|
1299
|
+
const baselineWorktrees = git(contributorRoot, "worktree", "list", "--porcelain");
|
|
1300
|
+
const { registry, roots, removalAttempts, internals } = materializationFailureRegistry(t, 2);
|
|
1301
|
+
try {
|
|
1302
|
+
assert.throws(() => registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor), CandidateViewError);
|
|
1303
|
+
assert.equal(removalAttempts(roots()[0]!), 1, "the first mismatched record must receive exactly one physical worktree removal");
|
|
1304
|
+
assert.equal(registry.hasProjection(lineageId, contributorRoot), false);
|
|
1305
|
+
assert.equal(internals.records.size, 0);
|
|
1306
|
+
assert.equal(git(contributorRoot, "worktree", "list", "--porcelain"), baselineWorktrees);
|
|
1307
|
+
assert.equal(existsSync(roots()[0]!), false);
|
|
1308
|
+
assert.equal(existsSync(roots()[1]!), false);
|
|
1309
|
+
|
|
1310
|
+
const restored = registry.restoreForFinalizeFromNative(lineageId, contributorRoot, descriptor);
|
|
1311
|
+
assert.equal(registry.resolveForFinalize(lineageId, contributorRoot).token, restored.token);
|
|
1312
|
+
registry.cleanup(restored.token);
|
|
1313
|
+
} finally {
|
|
1314
|
+
source.cleanup(frozen.token);
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test("dispatch restore removes a registered projection when materialization fails and records the hydration failure before retrying", (t) => {
|
|
1319
|
+
const contributorRoot = repository(t);
|
|
1320
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
1321
|
+
const source = new CandidateViewRegistry();
|
|
1322
|
+
const frozen = source.create({ contributorRoot });
|
|
1323
|
+
const lineageId = "dispatch-materialization-failure";
|
|
1324
|
+
const descriptor = {
|
|
1325
|
+
baseTree: frozen.baseTree,
|
|
1326
|
+
currentCandidateTree: frozen.candidateTree,
|
|
1327
|
+
paths: frozen.paths,
|
|
1328
|
+
intendedUntracked: [],
|
|
1329
|
+
projection: "workspace" as const,
|
|
1330
|
+
};
|
|
1331
|
+
const baselineWorktrees = git(contributorRoot, "worktree", "list", "--porcelain");
|
|
1332
|
+
const { registry, roots, internals } = materializationFailureRegistry(t, 1);
|
|
1333
|
+
try {
|
|
1334
|
+
let failure: unknown;
|
|
1335
|
+
try {
|
|
1336
|
+
registry.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, descriptor, ["review-reliability"]);
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
failure = error;
|
|
1339
|
+
}
|
|
1340
|
+
assert.ok(failure instanceof CandidateViewError);
|
|
1341
|
+
assert.equal(registry.hasProjection(lineageId, contributorRoot), false);
|
|
1342
|
+
assert.equal(registry.hasCurrentBinding(contributorRoot), false);
|
|
1343
|
+
assert.equal(internals.records.size, 0);
|
|
1344
|
+
assert.equal(git(contributorRoot, "worktree", "list", "--porcelain"), baselineWorktrees);
|
|
1345
|
+
assert.equal(existsSync(roots()[0]!), false);
|
|
1346
|
+
assert.deepEqual(registry.lastDispatchHydrationFailure(contributorRoot), {
|
|
1347
|
+
lineageId,
|
|
1348
|
+
reason: failure.reason,
|
|
1349
|
+
message: failure.message,
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
registry.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, descriptor, ["review-reliability"]);
|
|
1353
|
+
const restored = registry.resolveCurrentForLens("review-reliability", contributorRoot);
|
|
1354
|
+
assert.equal(registry.lastDispatchHydrationFailure(contributorRoot), undefined);
|
|
1355
|
+
registry.cleanup(restored.token);
|
|
1356
|
+
} finally {
|
|
1357
|
+
source.cleanup(frozen.token);
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test("candidate root resolution drift is exposed as a typed candidate-view error", () => {
|
|
1362
|
+
const missingRoot = join(tmpdir(), `gentle-pi-missing-root-${process.pid}-${Date.now()}`);
|
|
1363
|
+
assert.throws(
|
|
1364
|
+
() => new CandidateViewRegistry().hasCurrentBinding(missingRoot),
|
|
1365
|
+
(error: unknown) => error instanceof CandidateViewError && error.reason === "contributor-root-unresolvable",
|
|
1366
|
+
);
|
|
1367
|
+
});
|
|
1368
|
+
|
|
693
1369
|
test("live candidate drift blocks dispatch before candidate text can be injected", (t) => {
|
|
694
1370
|
const contributorRoot = repository(t);
|
|
695
1371
|
writeFileSync(join(contributorRoot, "tracked.txt"), "reviewed\n");
|
|
@@ -1039,3 +1715,213 @@ test("a projection labeled workspace but derived as a genuinely committed range
|
|
|
1039
1715
|
(error: unknown) => error instanceof CandidateViewError && error.reason === "projection-kind-drift",
|
|
1040
1716
|
);
|
|
1041
1717
|
});
|
|
1718
|
+
|
|
1719
|
+
test("candidate view treats an unborn staged repository base as Git's empty tree without needing HEAD^{commit}", (t) => {
|
|
1720
|
+
const contributorRoot = unbornRepository(t);
|
|
1721
|
+
const view = createCandidateView({ contributorRoot });
|
|
1722
|
+
try {
|
|
1723
|
+
const emptyTree = emptyTreeOf(contributorRoot);
|
|
1724
|
+
assert.equal(view.baseTree, emptyTree);
|
|
1725
|
+
assert.equal(view.baseCommit, "HEAD");
|
|
1726
|
+
assert.equal(view.committedOnly, false);
|
|
1727
|
+
assert.notEqual(view.candidateTree, emptyTree);
|
|
1728
|
+
assert.deepEqual(view.paths, ["staged.txt"]);
|
|
1729
|
+
assert.deepEqual(view.deletedPaths, []);
|
|
1730
|
+
} finally {
|
|
1731
|
+
view.cleanup();
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
test("candidate view of an unborn repository with no content yields an empty candidate scope instead of a misleading base-ref failure", (t) => {
|
|
1736
|
+
const contributorRoot = unbornRepository(t, false);
|
|
1737
|
+
const view = createCandidateView({ contributorRoot });
|
|
1738
|
+
try {
|
|
1739
|
+
const emptyTree = emptyTreeOf(contributorRoot);
|
|
1740
|
+
assert.equal(view.baseTree, emptyTree);
|
|
1741
|
+
assert.equal(view.candidateTree, emptyTree);
|
|
1742
|
+
assert.equal(view.baseCommit, "HEAD");
|
|
1743
|
+
assert.deepEqual(view.paths, []);
|
|
1744
|
+
assert.deepEqual(view.deletedPaths, []);
|
|
1745
|
+
} finally {
|
|
1746
|
+
view.cleanup();
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
test("candidate view materializes an unborn staged repository without mutating the contributor index or creating a phantom commit", (t) => {
|
|
1751
|
+
const contributorRoot = unbornRepository(t);
|
|
1752
|
+
const indexBefore = readFileSync(join(contributorRoot, ".git", "index"));
|
|
1753
|
+
const view = createCandidateView({ contributorRoot });
|
|
1754
|
+
try {
|
|
1755
|
+
// No phantom commit: HEAD stays unborn and no refs exist.
|
|
1756
|
+
assert.throws(() => git(contributorRoot, "rev-parse", "--verify", "HEAD"), /fatal/i);
|
|
1757
|
+
assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
|
|
1758
|
+
// Contributor's real index is untouched; materialization uses a private index file.
|
|
1759
|
+
assert.deepEqual(readFileSync(join(contributorRoot, ".git", "index")), indexBefore);
|
|
1760
|
+
// The materialized candidate carries the staged content, isolated from the contributor tree.
|
|
1761
|
+
assert.equal(readFileSync(join(view.root, "staged.txt"), "utf8"), "first staged\n");
|
|
1762
|
+
assert.deepEqual(view.paths, ["staged.txt"]);
|
|
1763
|
+
} finally {
|
|
1764
|
+
view.cleanup();
|
|
1765
|
+
}
|
|
1766
|
+
// Cleanup removed the orphan worktree; no worktree lingers and no commit appeared.
|
|
1767
|
+
assert.equal(git(contributorRoot, "worktree", "list").split("\n").filter((line) => line.includes("gentle-ai-candidate")).length, 0);
|
|
1768
|
+
assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
test("candidate view fails closed for a detached HEAD pointing at a missing commit, not an unborn empty tree", (t) => {
|
|
1772
|
+
const contributorRoot = unbornRepository(t);
|
|
1773
|
+
// Detach HEAD to a non-existent object id. This is a corrupt/missing-object
|
|
1774
|
+
// existing HEAD state, not a valid unborn symbolic HEAD, so it must fail
|
|
1775
|
+
// closed rather than producing an empty-tree unborn candidate.
|
|
1776
|
+
writeFileSync(join(contributorRoot, ".git", "HEAD"), "0".repeat(40));
|
|
1777
|
+
let failure: unknown;
|
|
1778
|
+
try {
|
|
1779
|
+
createCandidateView({ contributorRoot });
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
failure = error;
|
|
1782
|
+
}
|
|
1783
|
+
assert.ok(failure instanceof CandidateViewError, "a detached missing HEAD must fail closed");
|
|
1784
|
+
assert.notEqual((failure as CandidateViewError).reason, undefined);
|
|
1785
|
+
assert.ok((failure as CandidateViewError).reason !== "base-ref-moved");
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
test("candidate view fails closed when symbolic HEAD target ref exists but points to a missing object, not unborn", (t) => {
|
|
1789
|
+
const contributorRoot = repository(t);
|
|
1790
|
+
// Break the main ref: it exists (show-ref --exists returns 0) but points to
|
|
1791
|
+
// a missing object. This is a broken symbolic HEAD, not a valid unborn repo.
|
|
1792
|
+
writeFileSync(join(contributorRoot, ".git", "refs", "heads", "main"), `${"0".repeat(40)}\n`);
|
|
1793
|
+
let failure: unknown;
|
|
1794
|
+
try {
|
|
1795
|
+
createCandidateView({ contributorRoot });
|
|
1796
|
+
} catch (error) {
|
|
1797
|
+
failure = error;
|
|
1798
|
+
}
|
|
1799
|
+
assert.ok(failure instanceof CandidateViewError, "a broken symbolic HEAD must fail closed, not unborn");
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
test("candidate view probe does not convert a ref-existence probe timeout into an unborn empty-tree base", (t) => {
|
|
1803
|
+
const contributorRoot = unbornRepository(t);
|
|
1804
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
1805
|
+
if (args[0] === "rev-parse" && args.includes("--quiet")) throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT", killed: true });
|
|
1806
|
+
return execFileSync(file, args, options);
|
|
1807
|
+
};
|
|
1808
|
+
let failure: unknown;
|
|
1809
|
+
try {
|
|
1810
|
+
new CandidateViewRegistry(executor).create({ contributorRoot });
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
failure = error;
|
|
1813
|
+
}
|
|
1814
|
+
assert.ok(failure instanceof CandidateViewError, "ref-existence probe timeout must fail closed");
|
|
1815
|
+
assert.equal((failure as CandidateViewError).reason, "candidate-view-timeout");
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test("candidate view fails closed when the unborn ref probe exits with an unexpected status instead of treating it as unborn", (t) => {
|
|
1819
|
+
const contributorRoot = unbornRepository(t);
|
|
1820
|
+
// Only status 1 means "ref absent" (valid unborn). Any other nonzero status
|
|
1821
|
+
// (128, etc.) signals corruption or an I/O failure and must fail closed,
|
|
1822
|
+
// never masquerade as an unborn empty-tree base.
|
|
1823
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
1824
|
+
if (args[0] === "rev-parse" && args.includes("--quiet")) throw Object.assign(new Error("fatal: probe failed"), { status: 128 });
|
|
1825
|
+
return execFileSync(file, args, options);
|
|
1826
|
+
};
|
|
1827
|
+
let failure: unknown;
|
|
1828
|
+
try {
|
|
1829
|
+
new CandidateViewRegistry(executor).create({ contributorRoot });
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
failure = error;
|
|
1832
|
+
}
|
|
1833
|
+
assert.ok(failure instanceof CandidateViewError, "an unexpected unborn probe status must fail closed, not unborn");
|
|
1834
|
+
assert.equal((failure as CandidateViewError).reason, "candidate-view-git-failure");
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
test("candidate view treats an unborn sha256 repository base as the repository-native sha256 empty tree", (t) => {
|
|
1838
|
+
const contributorRoot = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-unborn-sha256-"));
|
|
1839
|
+
t.after(() => rmSync(contributorRoot, { recursive: true, force: true }));
|
|
1840
|
+
try {
|
|
1841
|
+
git(contributorRoot, "init", "--object-format=sha256", "-b", "main");
|
|
1842
|
+
} catch {
|
|
1843
|
+
t.skip("installed git does not support `git init --object-format=sha256`");
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
git(contributorRoot, "config", "user.name", "Candidate Test");
|
|
1847
|
+
git(contributorRoot, "config", "user.email", "candidate@example.invalid");
|
|
1848
|
+
writeFileSync(join(contributorRoot, "staged.txt"), "first staged\n");
|
|
1849
|
+
git(contributorRoot, "add", "staged.txt");
|
|
1850
|
+
const view = createCandidateView({ contributorRoot });
|
|
1851
|
+
try {
|
|
1852
|
+
const emptyTree = emptyTreeOf(contributorRoot);
|
|
1853
|
+
assert.equal(emptyTree.length, 64, "a sha256 repository must derive a 64-hex empty tree, not the hardcoded SHA-1 id");
|
|
1854
|
+
assert.equal(view.baseTree, emptyTree);
|
|
1855
|
+
assert.equal(view.baseCommit, "HEAD");
|
|
1856
|
+
assert.equal(view.committedOnly, false);
|
|
1857
|
+
assert.notEqual(view.candidateTree, emptyTree);
|
|
1858
|
+
assert.deepEqual(view.paths, ["staged.txt"]);
|
|
1859
|
+
assert.deepEqual(view.deletedPaths, []);
|
|
1860
|
+
} finally {
|
|
1861
|
+
view.cleanup();
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
test("candidate view unborn worktree falls back when --orphan is unsupported, preserving isolation, no phantom commit, and contributor immutability", (t) => {
|
|
1866
|
+
const contributorRoot = unbornRepository(t);
|
|
1867
|
+
const indexBefore = readFileSync(join(contributorRoot, ".git", "index"));
|
|
1868
|
+
// Intercept --orphan with status 129 (Git < 2.42 unknown option).
|
|
1869
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
1870
|
+
if (args[0] === "worktree" && args[1] === "add" && args.includes("--orphan")) throw Object.assign(new Error("unknown option"), { status: 129 });
|
|
1871
|
+
return execFileSync(file, args, options);
|
|
1872
|
+
};
|
|
1873
|
+
const view = new CandidateViewRegistry(executor).create({ contributorRoot });
|
|
1874
|
+
try {
|
|
1875
|
+
assert.equal(readFileSync(join(view.root, "staged.txt"), "utf8"), "first staged\n");
|
|
1876
|
+
assert.deepEqual(view.paths, ["staged.txt"]);
|
|
1877
|
+
assert.equal(view.baseTree, emptyTreeOf(contributorRoot));
|
|
1878
|
+
// No phantom commit; contributor index untouched.
|
|
1879
|
+
assert.throws(() => git(contributorRoot, "rev-parse", "--verify", "HEAD"), /fatal/i);
|
|
1880
|
+
assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
|
|
1881
|
+
assert.deepEqual(readFileSync(join(contributorRoot, ".git", "index")), indexBefore);
|
|
1882
|
+
} finally {
|
|
1883
|
+
view.cleanup();
|
|
1884
|
+
}
|
|
1885
|
+
assert.equal(git(contributorRoot, "worktree", "list").split("\n").filter((line) => line.includes("gentle-ai-candidate")).length, 0);
|
|
1886
|
+
assert.equal(git(contributorRoot, "rev-list", "--all").length, 0);
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
test("candidate view unborn worktree propagates a non-usage --orphan failure instead of falling back", (t) => {
|
|
1890
|
+
const contributorRoot = unbornRepository(t);
|
|
1891
|
+
// A non-129 status must not trigger the fallback.
|
|
1892
|
+
const calls: string[][] = [];
|
|
1893
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
1894
|
+
calls.push([...args]);
|
|
1895
|
+
if (args[0] === "worktree" && args[1] === "add" && args.includes("--orphan")) throw Object.assign(new Error("genuine failure"), { status: 128 });
|
|
1896
|
+
return execFileSync(file, args, options);
|
|
1897
|
+
};
|
|
1898
|
+
let failure: unknown;
|
|
1899
|
+
try { new CandidateViewRegistry(executor).create({ contributorRoot }); } catch (error) { failure = error; }
|
|
1900
|
+
assert.ok(failure instanceof CandidateViewError, "a non-usage --orphan failure must propagate");
|
|
1901
|
+
assert.equal((failure as CandidateViewError).reason, "candidate-view-git-failure");
|
|
1902
|
+
assert.ok(!calls.some((args) => args[0] === "commit-tree"), "the fallback must not create a temporary commit");
|
|
1903
|
+
assert.ok(!calls.some((args) => args[0] === "worktree" && args.includes("--detach")), "the fallback must not add a detached worktree");
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
test("candidate view cleans up a partially registered unborn fallback worktree when a later step fails", (t) => {
|
|
1907
|
+
const contributorRoot = unbornRepository(t);
|
|
1908
|
+
// Force the --orphan path to take the pre-2.42 fallback, then fail the
|
|
1909
|
+
// symbolic-ref rewrite that follows `worktree add --no-checkout --detach`.
|
|
1910
|
+
// The worktree is already registered with Git at that point; the cleanup
|
|
1911
|
+
// boundary must remove both the registered worktree and its directory.
|
|
1912
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
1913
|
+
if (args[0] === "worktree" && args[1] === "add" && args.includes("--orphan")) throw Object.assign(new Error("unknown option"), { status: 129 });
|
|
1914
|
+
if (args[0] === "symbolic-ref") throw Object.assign(new Error("symbolic-ref failed"), { status: 1 });
|
|
1915
|
+
return execFileSync(file, args, options);
|
|
1916
|
+
};
|
|
1917
|
+
let failure: unknown;
|
|
1918
|
+
try { new CandidateViewRegistry(executor).create({ contributorRoot }); } catch (error) { failure = error; }
|
|
1919
|
+
assert.ok(failure instanceof CandidateViewError, "the symbolic-ref failure must propagate");
|
|
1920
|
+
assert.equal((failure as CandidateViewError).reason, "candidate-view-git-failure");
|
|
1921
|
+
// No registered/admin worktree remains.
|
|
1922
|
+
assert.equal(git(contributorRoot, "worktree", "list").split("\n").filter((line) => line.includes("gentle-ai-candidate")).length, 0);
|
|
1923
|
+
// No candidate directory remains under the candidate-view parent.
|
|
1924
|
+
const parent = join(realpathSync(git(contributorRoot, "rev-parse", "--path-format=absolute", "--git-common-dir")), "gentle-ai", "candidate-views");
|
|
1925
|
+
const leftover = existsSync(parent) ? readdirSync(parent).filter((entry) => lstatSync(join(parent, entry)).isDirectory()) : [];
|
|
1926
|
+
assert.deepEqual(leftover, [], "no candidate directory remains after a partial unborn fallback failure");
|
|
1927
|
+
});
|