@boyingliu01/xp-gate 0.31.3 → 0.32.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/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +48 -13
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +48 -13
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +9 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +269 -13
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
- package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
- package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
- package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
- package/principles/adapters/cpp.ts +102 -50
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +548 -178
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/baseline.ts +58 -70
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate MW walkthrough evidence.
|
|
3
|
+
*
|
|
4
|
+
* @test REQ-DSH-012 delphi-review can produce Gate MW evidence
|
|
5
|
+
* @intent Build the .code-walkthrough-result.json document that the pre-push
|
|
6
|
+
* validator accepts, from a review the plugin actually ran.
|
|
7
|
+
* @covers AC-DSH-012-01
|
|
8
|
+
*
|
|
9
|
+
* Every field here mirrors a check in githooks/lib/validate-code-walkthrough.cjs.
|
|
10
|
+
* Two of those checks are easy to misread and are pinned by test:
|
|
11
|
+
*
|
|
12
|
+
* - result_type belongs to each EXPERT record. Putting it at the top level looks
|
|
13
|
+
* right, passes no check, and fails with a message that never mentions placement.
|
|
14
|
+
* - requested_model comes from the configured seat, never from the gateway's
|
|
15
|
+
* response. The gateway answers with its own naming (Qwen3_8-flash-next for a seat
|
|
16
|
+
* configured as local-qwen3.8-flash), so using the response tripped #574.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The document Gate MW reads. Field names are the validator's, not ours. */
|
|
20
|
+
export interface WalkthroughEvidence {
|
|
21
|
+
readonly commit: string
|
|
22
|
+
readonly branch: string
|
|
23
|
+
readonly timestamp: string
|
|
24
|
+
readonly expires: string
|
|
25
|
+
readonly consensus_ratio: number
|
|
26
|
+
readonly verdict: "APPROVED"
|
|
27
|
+
readonly experts: readonly EvidenceExpert[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EvidenceExpert {
|
|
31
|
+
readonly role: string
|
|
32
|
+
readonly requested_model: string
|
|
33
|
+
readonly resolved_model: string
|
|
34
|
+
/** Per-expert, not top-level. The validator reads it here. */
|
|
35
|
+
readonly result_type: "delphi_expert_result"
|
|
36
|
+
readonly channel: "external" | "local"
|
|
37
|
+
readonly verdict: "APPROVED"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One seat as observed by the review: what we asked for, and what answered. */
|
|
41
|
+
export interface SeatOutcome {
|
|
42
|
+
readonly role: string
|
|
43
|
+
/** The model configured for this role. Becomes requested_model. */
|
|
44
|
+
readonly model: string
|
|
45
|
+
/** The model the gateway reported. Becomes resolved_model. */
|
|
46
|
+
readonly resolved: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BuildEvidenceInput {
|
|
50
|
+
readonly head: string
|
|
51
|
+
readonly branch: string
|
|
52
|
+
readonly seats: readonly SeatOutcome[]
|
|
53
|
+
readonly verdict: string
|
|
54
|
+
readonly now: Date
|
|
55
|
+
/** Gateway URL, used to label provenance. */
|
|
56
|
+
readonly baseUrl?: string
|
|
57
|
+
/** Observed agreement. Defaults to unanimous, which is what a 3-seat APPROVED run is. */
|
|
58
|
+
readonly consensusRatio?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The validator requires exactly three seats. */
|
|
62
|
+
const REQUIRED_SEATS = 3;
|
|
63
|
+
|
|
64
|
+
/** Evidence is valid for exactly this long; the validator checks the delta. */
|
|
65
|
+
const VALIDITY_MS = 3_600_000;
|
|
66
|
+
|
|
67
|
+
/** The validator rejects anything below this. */
|
|
68
|
+
const MIN_CONSENSUS = 0.9;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Label a gateway as local or external.
|
|
72
|
+
*
|
|
73
|
+
* Only loopback counts as local: the validator uses this field as provenance, so a
|
|
74
|
+
* private-network host is still external — nothing about it is verifiable from here.
|
|
75
|
+
*/
|
|
76
|
+
export function evidenceChannel(baseUrl: string): "local" | "external" {
|
|
77
|
+
const lowered = baseUrl.toLowerCase()
|
|
78
|
+
return lowered.includes("localhost") || lowered.includes("127.0.0.1") || lowered.includes("[::1]")
|
|
79
|
+
? "local"
|
|
80
|
+
: "external"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Refuse to emit a document the validator would reject, and say which field. */
|
|
84
|
+
function assertSeatsUsable(seats: readonly SeatOutcome[]): void {
|
|
85
|
+
if (seats.length !== REQUIRED_SEATS) {
|
|
86
|
+
throw new Error(`evidence needs exactly ${REQUIRED_SEATS} seats; got ${seats.length}`)
|
|
87
|
+
}
|
|
88
|
+
const requested = new Set<string>()
|
|
89
|
+
const resolved = new Set<string>()
|
|
90
|
+
for (const seat of seats) {
|
|
91
|
+
if (seat.resolved.trim() === "") {
|
|
92
|
+
throw new Error(`seat "${seat.role}" has no resolved_model; a failed seat cannot produce evidence`)
|
|
93
|
+
}
|
|
94
|
+
// Both are checked case-insensitively by some validator paths, so assert on the
|
|
95
|
+
// stricter reading: duplicated IDs mean one model answered twice.
|
|
96
|
+
if (requested.has(seat.model.trim())) throw new Error(`duplicate requested_model "${seat.model}"`)
|
|
97
|
+
if (resolved.has(seat.resolved.trim().toLowerCase())) {
|
|
98
|
+
throw new Error(`duplicate resolved_model "${seat.resolved}"`)
|
|
99
|
+
}
|
|
100
|
+
requested.add(seat.model.trim())
|
|
101
|
+
resolved.add(seat.resolved.trim().toLowerCase())
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the evidence document.
|
|
107
|
+
*
|
|
108
|
+
* Refuses on anything the validator would reject rather than emitting a document
|
|
109
|
+
* that fails at push time, where the cause is much harder to see.
|
|
110
|
+
*/
|
|
111
|
+
export function buildEvidence(input: BuildEvidenceInput): WalkthroughEvidence {
|
|
112
|
+
if (input.verdict !== "APPROVED") {
|
|
113
|
+
throw new Error(`evidence requires an APPROVED review; got ${input.verdict}`)
|
|
114
|
+
}
|
|
115
|
+
assertSeatsUsable(input.seats)
|
|
116
|
+
|
|
117
|
+
const ratio = input.consensusRatio ?? 1
|
|
118
|
+
if (ratio < MIN_CONSENSUS || ratio > 1) {
|
|
119
|
+
throw new Error(`consensus_ratio must be ${MIN_CONSENSUS} through 1; got ${ratio}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const channel = evidenceChannel(input.baseUrl ?? "")
|
|
123
|
+
const expires = new Date(input.now.getTime() + VALIDITY_MS)
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
commit: input.head,
|
|
127
|
+
branch: input.branch,
|
|
128
|
+
timestamp: input.now.toISOString(),
|
|
129
|
+
expires: expires.toISOString(),
|
|
130
|
+
consensus_ratio: ratio,
|
|
131
|
+
verdict: "APPROVED",
|
|
132
|
+
experts: input.seats.map((seat) => ({
|
|
133
|
+
role: seat.role,
|
|
134
|
+
requested_model: seat.model,
|
|
135
|
+
resolved_model: seat.resolved,
|
|
136
|
+
result_type: "delphi_expert_result",
|
|
137
|
+
channel,
|
|
138
|
+
verdict: "APPROVED",
|
|
139
|
+
})),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest"
|
|
|
2
2
|
import type { ShellExecRequest, ShellRunResult } from "@deepseek-ai/dsh-shell"
|
|
3
3
|
import { renderGateResult, runXpGate, type XpGateShell } from "./gate-runner.js"
|
|
4
4
|
|
|
5
|
+
/** A timeout distinct from the fixture default, so a passing assertion proves it was forwarded. */
|
|
6
|
+
const FORWARDED_TIMEOUT_MS = 5000;
|
|
7
|
+
|
|
5
8
|
function result(over: Partial<ShellRunResult> = {}): ShellRunResult {
|
|
6
9
|
return {
|
|
7
10
|
exitCode: 0,
|
|
@@ -24,21 +27,26 @@ function makeShell(finalResult: ShellRunResult): { shell: XpGateShell; requests:
|
|
|
24
27
|
command: req.command,
|
|
25
28
|
workdir: req.workdir ?? "/workspace",
|
|
26
29
|
timeoutMs: req.timeoutMs ?? 1000,
|
|
30
|
+
// 0.2.0-rc.2 requires the resolved expiry policy even though the request
|
|
31
|
+
// leaves it optional ("kill" is the executor default).
|
|
32
|
+
onExpiry: "kill",
|
|
27
33
|
stdoutMaxBytes: 1_000_000,
|
|
28
34
|
signal: req.signal,
|
|
29
35
|
sandboxPolicy: undefined,
|
|
30
36
|
}
|
|
31
37
|
},
|
|
32
|
-
|
|
33
|
-
return Promise.resolve(finalResult)
|
|
38
|
+
execute() {
|
|
39
|
+
return Promise.resolve({ result: () => Promise.resolve(finalResult) })
|
|
34
40
|
},
|
|
35
41
|
}
|
|
36
42
|
return { shell, requests }
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
describe("renderGateResult", () => {
|
|
40
|
-
it("renders stdout text", () => {
|
|
41
|
-
|
|
46
|
+
it("renders stdout text under an explicit PASS verdict", () => {
|
|
47
|
+
// A bare body would let the model mistake silence for success, so every
|
|
48
|
+
// render now leads with one of the three verdicts.
|
|
49
|
+
expect(renderGateResult(result())).toBe("[XP-Gate] PASS\nok")
|
|
42
50
|
})
|
|
43
51
|
|
|
44
52
|
it("appends [exit code: N] on non-zero exit", () => {
|
|
@@ -73,10 +81,10 @@ describe("runXpGate", () => {
|
|
|
73
81
|
it("passes command/workdir/timeoutMs/signal to the shell", async () => {
|
|
74
82
|
const signal = new AbortController().signal
|
|
75
83
|
const { shell, requests } = makeShell(result())
|
|
76
|
-
await runXpGate(shell, signal, "xp-gate check 'src'", "/workspace",
|
|
84
|
+
await runXpGate(shell, signal, "xp-gate check 'src'", "/workspace", FORWARDED_TIMEOUT_MS)
|
|
77
85
|
expect(requests[0]?.command).toBe("xp-gate check 'src'")
|
|
78
86
|
expect(requests[0]?.workdir).toBe("/workspace")
|
|
79
|
-
expect(requests[0]?.timeoutMs).toBe(
|
|
87
|
+
expect(requests[0]?.timeoutMs).toBe(FORWARDED_TIMEOUT_MS)
|
|
80
88
|
expect(requests[0]?.signal).toBe(signal)
|
|
81
89
|
})
|
|
82
90
|
|
|
@@ -5,14 +5,49 @@ import type { ShellExecRequest, ShellExecSpec, ShellRunResult } from "@deepseek-
|
|
|
5
5
|
/**
|
|
6
6
|
* Structural view of `ctx.shell` so the runner is testable with a fake shell
|
|
7
7
|
* without importing the abstract `ShellExecutor` service.
|
|
8
|
+
*
|
|
9
|
+
* The shape tracks the 0.2.0-rc.2 seam: `execute()` returns a handle whose
|
|
10
|
+
* `result()` settles into the run result. Earlier 0.1.x exposed a single `run()`,
|
|
11
|
+
* which is why a stale devDependency made this compile while the real runtime
|
|
12
|
+
* had already moved on.
|
|
8
13
|
*/
|
|
9
14
|
export interface XpGateShell {
|
|
10
15
|
resolve(request: ShellExecRequest): ShellExecSpec
|
|
11
|
-
|
|
16
|
+
execute(spec: ShellExecSpec): Promise<{ result(): Promise<ShellRunResult> }>
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
19
|
+
/** The three verdicts a gate run can produce. There is deliberately no fourth. */
|
|
20
|
+
export type GateVerdict = "PASS" | "GATE FAILED" | "INDETERMINATE"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Classify a completed shell run.
|
|
24
|
+
*
|
|
25
|
+
* The order below is load-bearing and must not be rearranged:
|
|
26
|
+
*
|
|
27
|
+
* 1. timeout / truncation -> INDETERMINATE (a killed or clipped run yields no verdict)
|
|
28
|
+
* 2. exit 0 with no stdout -> INDETERMINATE (the command succeeded but said nothing)
|
|
29
|
+
* 3. exit 0 with stdout -> PASS
|
|
30
|
+
* 4. otherwise -> GATE FAILED (nonzero with output) or INDETERMINATE (silent)
|
|
31
|
+
*
|
|
32
|
+
* Step 1 must precede step 3: a timed-out process can still report exit 0 from
|
|
33
|
+
* the state it held when the deadline killed it, so testing `exitCode === 0`
|
|
34
|
+
* first would render a timeout as a pass. That is the exact defect this project
|
|
35
|
+
* fixed in Gate 8 and Gate 10, and the plugin must not reintroduce it.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyGateRun(result: ShellRunResult): GateVerdict {
|
|
38
|
+
const stdout = result.stdout.text.trim()
|
|
39
|
+
|
|
40
|
+
if (result.timedOut) return "INDETERMINATE"
|
|
41
|
+
if (result.stdout.truncated) return "INDETERMINATE"
|
|
42
|
+
if (result.exitCode === 0) return stdout.length > 0 ? "PASS" : "INDETERMINATE"
|
|
43
|
+
|
|
44
|
+
// Nonzero: a verdict exists only when the run produced something to show.
|
|
45
|
+
const stderr = result.stderr.text.trim()
|
|
46
|
+
return stdout.length > 0 || stderr.length > 0 ? "GATE FAILED" : "INDETERMINATE"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Detailed diagnostics appended after the verdict line, when present. */
|
|
50
|
+
function renderDetails(result: ShellRunResult): string[] {
|
|
16
51
|
const lines: string[] = []
|
|
17
52
|
const stdout = result.stdout.text.trim()
|
|
18
53
|
if (stdout.length > 0) lines.push(stdout)
|
|
@@ -32,7 +67,34 @@ export function renderGateResult(result: ShellRunResult): string {
|
|
|
32
67
|
const spill = result.stderr.spillPath !== undefined ? `, full output: ${result.stderr.spillPath}` : ""
|
|
33
68
|
lines.push(`[stderr truncated${spill}]`)
|
|
34
69
|
}
|
|
35
|
-
return lines
|
|
70
|
+
return lines
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Install hint appended whenever no verdict could be reached. */
|
|
74
|
+
const INDETERMINATE_HINT =
|
|
75
|
+
"The gate produced no usable verdict. Check that the xp-gate CLI is installed " +
|
|
76
|
+
"and runnable (npm i -g @boyingliu01/xp-gate), then retry."
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a completed shell run as model-facing text.
|
|
80
|
+
*
|
|
81
|
+
* Every run resolves to exactly one of three verdicts. A run that could not
|
|
82
|
+
* establish a verdict says INDETERMINATE and states explicitly that it is not a
|
|
83
|
+
* pass, because an empty output read as a green check is worse than no output.
|
|
84
|
+
*/
|
|
85
|
+
export function renderGateResult(result: ShellRunResult): string {
|
|
86
|
+
const verdict = classifyGateRun(result)
|
|
87
|
+
const lines = renderDetails(result)
|
|
88
|
+
|
|
89
|
+
if (verdict === "PASS") {
|
|
90
|
+
return `[XP-Gate] PASS\n${lines.join("\n")}`
|
|
91
|
+
}
|
|
92
|
+
if (verdict === "GATE FAILED") {
|
|
93
|
+
return `[XP-Gate] GATE FAILED\n${lines.join("\n")}`
|
|
94
|
+
}
|
|
95
|
+
return [`[XP-Gate] INDETERMINATE — gate produced no output; NOT a pass`, ...lines, INDETERMINATE_HINT].join(
|
|
96
|
+
"\n",
|
|
97
|
+
)
|
|
36
98
|
}
|
|
37
99
|
|
|
38
100
|
/**
|
|
@@ -48,7 +110,8 @@ export async function runXpGate(
|
|
|
48
110
|
timeoutMs: number,
|
|
49
111
|
): Promise<string> {
|
|
50
112
|
const spec = shell.resolve({ command, workdir, timeoutMs, signal })
|
|
51
|
-
const
|
|
113
|
+
const execution = await shell.execute(spec)
|
|
114
|
+
const result = await execution.result()
|
|
52
115
|
if (result.aborted) {
|
|
53
116
|
const error = new HarnessError("tool call aborted", TOOL_ABORTED)
|
|
54
117
|
error.name = "AbortError"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DSH-012 delphi-review can produce Gate MW evidence
|
|
3
|
+
* @intent Verify HEAD and branch are read from the repository the review ran in, so
|
|
4
|
+
* the evidence names the commit the validator will compare against.
|
|
5
|
+
* @covers AC-DSH-012-02
|
|
6
|
+
*
|
|
7
|
+
* Runs against this repository rather than a scratch one: the point is that the
|
|
8
|
+
* values agree with what git itself reports here, which is exactly what the
|
|
9
|
+
* pre-push validator does with them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process"
|
|
13
|
+
import { describe, expect, it } from "vitest"
|
|
14
|
+
import { readGitContext } from "./git-context.js"
|
|
15
|
+
|
|
16
|
+
/** Ask git directly, the way the validator does. */
|
|
17
|
+
function git(args: readonly string[]): string {
|
|
18
|
+
return execFileSync("git", args, { encoding: "utf8" }).trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim()
|
|
22
|
+
|
|
23
|
+
describe("readGitContext", () => {
|
|
24
|
+
it("reports the same HEAD and branch git reports", () => {
|
|
25
|
+
const context = readGitContext(repoRoot)
|
|
26
|
+
expect(context.head).toBe(git(["rev-parse", "HEAD"]))
|
|
27
|
+
expect(context.branch).toBe(git(["rev-parse", "--abbrev-ref", "HEAD"]))
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("returns a full 40-character commit id, not an abbreviation", () => {
|
|
31
|
+
// The validator compares commit exactly; an abbreviation would never match.
|
|
32
|
+
expect(readGitContext(repoRoot).head).toMatch(/^[0-9a-f]{40}$/)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("fails loudly outside a repository rather than returning blanks", () => {
|
|
36
|
+
// Blank values would produce evidence rejected at push time with no clue why.
|
|
37
|
+
expect(() => readGitContext("/")).toThrow()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal git context for evidence.
|
|
3
|
+
*
|
|
4
|
+
* @test REQ-DSH-012 delphi-review can produce Gate MW evidence
|
|
5
|
+
* @intent Read HEAD and the current branch name, which the walkthrough evidence must
|
|
6
|
+
* name exactly, without adding a git library dependency.
|
|
7
|
+
* @covers AC-DSH-012-02
|
|
8
|
+
*
|
|
9
|
+
* Spawned git, not a parsed .git directory: worktrees and detached HEAD have
|
|
10
|
+
* layouts that are easy to misread, and getting either wrong produces evidence the
|
|
11
|
+
* validator rejects at push time with no hint about why.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process"
|
|
15
|
+
|
|
16
|
+
/** The two facts the validator compares against. */
|
|
17
|
+
export interface GitContext {
|
|
18
|
+
readonly head: string
|
|
19
|
+
readonly branch: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read HEAD and the branch.
|
|
24
|
+
*
|
|
25
|
+
* The validator compares `branch` to the name the push reports; a detached HEAD
|
|
26
|
+
* reports "HEAD", which is what git itself would say, so it is passed through
|
|
27
|
+
* rather than guessed at.
|
|
28
|
+
*/
|
|
29
|
+
export function readGitContext(cwd: string): GitContext {
|
|
30
|
+
const run = (args: readonly string[]): string =>
|
|
31
|
+
execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return { head: run(["rev-parse", "HEAD"]), branch: run(["rev-parse", "--abbrev-ref", "HEAD"]) }
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw new Error(`cannot read git context in ${cwd}: ${(error as Error).message}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DSH-015 sprint-phase writes are gated the way the IDE plugin gates them
|
|
3
|
+
* @intent Verify the pre-execute guard allows non-sprint projects, denies a sprint
|
|
4
|
+
* that has no APPROVED review, and never throws into the tool path.
|
|
5
|
+
* @covers AC-DSH-015-01 AC-DSH-015-02
|
|
6
|
+
*
|
|
7
|
+
* Mirrors plugins/claude-code/bin/delphi-review-guard.sh, whose degrade rules are
|
|
8
|
+
* already proven: an absent .sprint-state means "not a sprint project", so gating it
|
|
9
|
+
* would punish every ordinary repository that installed the plugin.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { tmpdir } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
16
|
+
import { delphiGuardDecision } from "./hooks.js"
|
|
17
|
+
|
|
18
|
+
let dir: string
|
|
19
|
+
|
|
20
|
+
/** Build a scratch project, optionally with sprint state and a verdict. */
|
|
21
|
+
function project(state: "none" | "no-verdict" | "approved" | "rejected"): string {
|
|
22
|
+
const root = mkdtempSync(join(dir, "proj-"))
|
|
23
|
+
if (state === "none") return root
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(join(root, ".sprint-state"), { recursive: true })
|
|
26
|
+
if (state !== "no-verdict") {
|
|
27
|
+
const verdict = state === "approved" ? "APPROVED" : "REQUEST_CHANGES"
|
|
28
|
+
writeFileSync(
|
|
29
|
+
join(root, ".sprint-state", "delphi-reviewed.json"),
|
|
30
|
+
JSON.stringify({ verdict, timestamp: "2026-10-27T10:00:00.000Z" }),
|
|
31
|
+
"utf8",
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error(`cannot build scratch project: ${(error as Error).message}`)
|
|
36
|
+
}
|
|
37
|
+
return root
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
dir = mkdtempSync(join(tmpdir(), "xp-gate-hooks-"))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe("delphiGuardDecision", () => {
|
|
49
|
+
it("allows a project with no sprint state at all", () => {
|
|
50
|
+
// The plugin is installed repo-wide; gating a non-sprint repo would break it.
|
|
51
|
+
expect(delphiGuardDecision(project("none"), "edit")).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("denies a sprint whose review has not been recorded", () => {
|
|
55
|
+
expect(delphiGuardDecision(project("no-verdict"), "write")).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("denies a sprint whose review was rejected", () => {
|
|
59
|
+
const decision = delphiGuardDecision(project("rejected"), "edit")
|
|
60
|
+
expect(decision).toBeDefined()
|
|
61
|
+
// The reason has to name the current verdict, or the model cannot act on it.
|
|
62
|
+
expect(decision?.reason).toContain("REQUEST_CHANGES")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("allows a sprint once the review is APPROVED", () => {
|
|
66
|
+
expect(delphiGuardDecision(project("approved"), "edit")).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("ignores read-only tools", () => {
|
|
70
|
+
// Reading is never what the guard protects against.
|
|
71
|
+
expect(delphiGuardDecision(project("rejected"), "read")).toBeUndefined()
|
|
72
|
+
expect(delphiGuardDecision(project("rejected"), "gate-check")).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("treats a corrupt verdict file as unreviewed rather than crashing", () => {
|
|
76
|
+
const root = project("none")
|
|
77
|
+
try {
|
|
78
|
+
mkdirSync(join(root, ".sprint-state"), { recursive: true })
|
|
79
|
+
writeFileSync(join(root, ".sprint-state", "delphi-reviewed.json"), "{not json", "utf8")
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`cannot corrupt verdict: ${(error as Error).message}`)
|
|
82
|
+
}
|
|
83
|
+
// A parse failure must not surface as a thrown error inside the tool pipeline.
|
|
84
|
+
expect(() => delphiGuardDecision(root, "edit")).not.toThrow()
|
|
85
|
+
expect(delphiGuardDecision(root, "edit")).toBeDefined()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH-native hooks: the delphi-review guard and the save-time principles check.
|
|
3
|
+
*
|
|
4
|
+
* @test REQ-DSH-015 sprint-phase writes are gated the way the IDE plugin gates them
|
|
5
|
+
* @intent Port the Claude Code guard and save-check to DSH's own pre/post-execute
|
|
6
|
+
* seams, keeping the degrade rules that made them safe there.
|
|
7
|
+
* @covers AC-DSH-015-01
|
|
8
|
+
*
|
|
9
|
+
* Degrade semantics are copied from plugins/claude-code/bin/delphi-review-guard.sh,
|
|
10
|
+
* which has been in use long enough to have settled them:
|
|
11
|
+
*
|
|
12
|
+
* no .sprint-state -> ALLOW (not a sprint project; gating it would punish every
|
|
13
|
+
* ordinary repo that installed the plugin)
|
|
14
|
+
* state, no verdict -> DENY (sprint is open, review not done)
|
|
15
|
+
* verdict != APPROVED-> DENY (reason names the actual verdict)
|
|
16
|
+
* APPROVED -> ALLOW
|
|
17
|
+
*
|
|
18
|
+
* The guard only ever inspects state. It must not throw: a throw inside pre-execute
|
|
19
|
+
* aborts the tool call, which is a worse failure than letting an edit through.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
23
|
+
import { join } from "node:path"
|
|
24
|
+
|
|
25
|
+
/** Tools that modify files. Read-only tools are never gated. */
|
|
26
|
+
const EDIT_TOOLS: ReadonlySet<string> = new Set(["write", "edit", "str_replace_editor"])
|
|
27
|
+
|
|
28
|
+
/** Where Sprint Flow records the review verdict. */
|
|
29
|
+
const SPRINT_STATE_DIR = ".sprint-state"
|
|
30
|
+
const VERDICT_FILE = "delphi-reviewed.json"
|
|
31
|
+
|
|
32
|
+
/** Denial the guard returns; shape matches DSH's PreToolDecision deny variant. */
|
|
33
|
+
export interface GuardDenial {
|
|
34
|
+
readonly kind: "deny"
|
|
35
|
+
readonly reason: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The file extensions the principles engine actually covers. */
|
|
39
|
+
const CHECKABLE_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
40
|
+
".ts",
|
|
41
|
+
".tsx",
|
|
42
|
+
".js",
|
|
43
|
+
".jsx",
|
|
44
|
+
".py",
|
|
45
|
+
".go",
|
|
46
|
+
".java",
|
|
47
|
+
".kt",
|
|
48
|
+
".c",
|
|
49
|
+
".cpp",
|
|
50
|
+
".h",
|
|
51
|
+
".swift",
|
|
52
|
+
".m",
|
|
53
|
+
".dart",
|
|
54
|
+
".sh",
|
|
55
|
+
".ps1",
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
/** Does this tool modify files? */
|
|
59
|
+
export function isEditTool(name: string): boolean {
|
|
60
|
+
return EDIT_TOOLS.has(name)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Should a save-time check run for this path?
|
|
65
|
+
*
|
|
66
|
+
* Documents and data files are excluded on purpose: they are a large share of the
|
|
67
|
+
* commits in repositories using xp-gate, and the engine has nothing to say about
|
|
68
|
+
* them. Skipping them removes most of the hook's cost.
|
|
69
|
+
*/
|
|
70
|
+
export function isCheckablePath(path: string): boolean {
|
|
71
|
+
const lowered = path.toLowerCase()
|
|
72
|
+
const dot = lowered.lastIndexOf(".")
|
|
73
|
+
if (dot < 0) return false
|
|
74
|
+
return CHECKABLE_EXTENSIONS.has(lowered.slice(dot))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read the recorded verdict, or a reason it cannot be read. */
|
|
78
|
+
function readVerdict(root: string): { verdict?: string; unreadable?: string } {
|
|
79
|
+
const file = join(root, SPRINT_STATE_DIR, VERDICT_FILE)
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as { verdict?: unknown }
|
|
82
|
+
const verdict = typeof parsed.verdict === "string" ? parsed.verdict : ""
|
|
83
|
+
return { verdict }
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// Corrupt JSON is a real state (a half-written file, a merge conflict). Treat it
|
|
86
|
+
// as "not reviewed" and say why, rather than throwing into the tool pipeline.
|
|
87
|
+
return { unreadable: (error as Error).message }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether an edit may proceed.
|
|
93
|
+
*
|
|
94
|
+
* Returns undefined to mean "allow" so the caller can `if (denial) return denial`.
|
|
95
|
+
*/
|
|
96
|
+
export function delphiGuardDecision(root: string, toolName: string): GuardDenial | undefined {
|
|
97
|
+
if (!isEditTool(toolName)) return undefined
|
|
98
|
+
if (!existsSync(join(root, SPRINT_STATE_DIR))) return undefined
|
|
99
|
+
|
|
100
|
+
const verdictFile = join(root, SPRINT_STATE_DIR, VERDICT_FILE)
|
|
101
|
+
if (!existsSync(verdictFile)) {
|
|
102
|
+
return {
|
|
103
|
+
kind: "deny",
|
|
104
|
+
reason:
|
|
105
|
+
"delphi-review has not run for this sprint. Complete the Phase 2 delphi-review " +
|
|
106
|
+
"before modifying code, then retry.",
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { verdict, unreadable } = readVerdict(root)
|
|
111
|
+
if (unreadable !== undefined) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "deny",
|
|
114
|
+
reason: `${VERDICT_FILE} is not readable as JSON (${unreadable}). Re-run delphi-review.`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (verdict !== "APPROVED") {
|
|
118
|
+
return {
|
|
119
|
+
kind: "deny",
|
|
120
|
+
reason:
|
|
121
|
+
`delphi-review verdict is "${verdict || "(missing)"}", not APPROVED. ` +
|
|
122
|
+
"Address the findings and re-run delphi-review, then retry.",
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined
|
|
126
|
+
}
|