@boyingliu01/xp-gate 0.31.4 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +65 -16
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +65 -16
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +101 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +259 -57
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. 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
- run() {
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
- expect(renderGateResult(result())).toBe("ok")
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", 5000)
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(5000)
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
- run(spec: ShellExecSpec): Promise<ShellRunResult>
16
+ execute(spec: ShellExecSpec): Promise<{ result(): Promise<ShellRunResult> }>
12
17
  }
13
18
 
14
- /** Render a completed shell run as a single model-facing text block. */
15
- export function renderGateResult(result: ShellRunResult): string {
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.length > 0 ? lines.join("\n") : "[XP-Gate] command completed (no output)."
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 result = await shell.run(spec)
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
+ }