@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.
Files changed (151) 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 +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  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 +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +269 -13
  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-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { readFileSync } from "node:fs"
3
+ import { createRequire } from "node:module"
4
+ import { join, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)))
8
+
9
+ const DSH_PEERS = ["@deepseek-ai/dsh-llm", "@deepseek-ai/dsh-shell", "@deepseek-ai/dsh-tools"] as const
10
+
11
+ interface PkgShape {
12
+ peerDependencies?: Record<string, string>
13
+ }
14
+
15
+ function readPkg(): PkgShape {
16
+ const path = join(PKG_ROOT, "package.json")
17
+ try {
18
+ return JSON.parse(readFileSync(path, "utf8")) as PkgShape
19
+ } catch (error) {
20
+ throw new Error(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`)
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Minimal semver comparison for MAJOR.MINOR.PATCH[-PRERELEASE].
26
+ *
27
+ * Deliberately hand-rolled: the plugin ships zero runtime dependencies, and the
28
+ * only thing this test needs is ordinal comparison of a handful of versions.
29
+ * Adding `semver` as a devDependency would let the test pass while telling us
30
+ * nothing about whether DSH's own resolver agrees — the assertion below pins
31
+ * the *contract* (which versions we claim), and the scratch-install check in
32
+ * AC-2 pins the *behaviour* (whether DSH accepts it).
33
+ */
34
+ interface Parsed {
35
+ major: number
36
+ minor: number
37
+ patch: number
38
+ pre: string[]
39
+ }
40
+
41
+ function parse(v: string): Parsed {
42
+ const [core, pre = ""] = v.split("-", 2)
43
+ const [major, minor, patch] = core.split(".").map(Number)
44
+ return { major, minor, patch, pre: pre === "" ? [] : pre.split(".") }
45
+ }
46
+
47
+ function cmpPre(a: string[], b: string[]): number {
48
+ // An empty prerelease sorts AFTER any prerelease (1.0.0 > 1.0.0-rc.1).
49
+ if (a.length === 0 && b.length === 0) return 0
50
+ if (a.length === 0) return 1
51
+ if (b.length === 0) return -1
52
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
53
+ const x = a[i]
54
+ const y = b[i]
55
+ if (x === undefined) return -1
56
+ if (y === undefined) return 1
57
+ const nx = Number(x)
58
+ const ny = Number(y)
59
+ // Number.isNaN on a non-numeric string means "this identifier is alphanumeric".
60
+ const xIsAlpha = Number.isNaN(nx)
61
+ const yIsAlpha = Number.isNaN(ny)
62
+ if (!xIsAlpha && !yIsAlpha) {
63
+ if (nx !== ny) return nx < ny ? -1 : 1
64
+ continue
65
+ }
66
+ // semver: numeric identifiers always sort BELOW alphanumeric ones.
67
+ if (xIsAlpha !== yIsAlpha) return xIsAlpha ? 1 : -1
68
+ if (x !== y) return x < y ? -1 : 1
69
+ }
70
+ return 0
71
+ }
72
+
73
+ function compare(a: string, b: string): number {
74
+ const x = parse(a)
75
+ const y = parse(b)
76
+ if (x.major !== y.major) return x.major < y.major ? -1 : 1
77
+ if (x.minor !== y.minor) return x.minor < y.minor ? -1 : 1
78
+ if (x.patch !== y.patch) return x.patch < y.patch ? -1 : 1
79
+ return cmpPre(x.pre, y.pre)
80
+ }
81
+
82
+ /**
83
+ * Evaluate the range shapes this package may declare.
84
+ *
85
+ * `^` matters specifically because it was the original (broken) declaration:
86
+ * under 0.x, `^0.1.1-rc.2` allows patch bumps only, so it silently excludes
87
+ * every 0.2.x runtime. Modelling it here keeps that failure expressible as a
88
+ * red assertion instead of a crash.
89
+ */
90
+ function satisfiesRange(version: string, range: string): boolean {
91
+ const trimmed = range.trim()
92
+
93
+ const bounded = /^>=(\S+)\s+<(\S+)$/.exec(trimmed)
94
+ if (bounded !== null) {
95
+ return compare(version, bounded[1]) >= 0 && compare(version, bounded[2]) < 0
96
+ }
97
+
98
+ const caret = /^\^(\S+)$/.exec(trimmed)
99
+ if (caret !== null) {
100
+ const lower = caret[1]
101
+ const { major, minor } = parse(lower)
102
+ if (compare(version, lower) < 0) return false
103
+ // npm caret semantics for 0.x pins the MINOR. Crucially, npm expands
104
+ // ^0.1.1-rc.2 => >=0.1.1-rc.2 <0.2.0-0
105
+ // i.e. caret ALREADY carries the `-0` sentinel, so it excludes every 0.2.x
106
+ // prerelease as well as 0.2.0 itself. That is precisely why the old
107
+ // declaration broke on a 0.2.0-rc.2 runtime, and why the fix must spell out
108
+ // `<0.3.0-0` rather than `<0.3.0` — the same sentinel idiom npm uses itself.
109
+ // (Both facts are pinned against the real `semver` package by the oracle test.)
110
+ const upper = `${major}.${minor + 1}.0-0`
111
+ return compare(version, upper) < 0
112
+ }
113
+
114
+ throw new Error(`unsupported range shape: ${range}`)
115
+ }
116
+
117
+ describe("DSH peer compatibility (install gate)", () => {
118
+ /**
119
+ * @test REQ-DSH-017
120
+ * @intent 用真实 semver 校准本文件手写的比较器,避免"测试自己的实现"式假绿。
121
+ * semver 是仓库根的开发依赖,仅在测试期作为 oracle 使用,
122
+ * 不进入插件运行时依赖(插件 ships zero runtime deps)。
123
+ * @covers AC-DSH-017-04
124
+ */
125
+ it("手写比较器与真实 semver 的判定一致(oracle 校准)", async () => {
126
+ // Resolved through createRequire rather than a static import: `semver` is a
127
+ // devDependency of the repo root, not of this plugin package, and this
128
+ // package ships zero runtime deps. Keeping it dynamic means a missing
129
+ // semver breaks only this oracle test, never the plugin's typecheck.
130
+ const require_ = createRequire(import.meta.url)
131
+ const semver = require_("semver") as {
132
+ satisfies: (v: string, r: string, o?: { includePrerelease?: boolean }) => boolean
133
+ }
134
+ const opts = { includePrerelease: true }
135
+ const versions = ["0.1.0", "0.1.1-rc.2", "0.1.2", "0.2.0-rc.1", "0.2.0-rc.2", "0.2.1", "0.2.9", "0.3.0-rc.1", "0.3.0"]
136
+ const ranges = ["^0.1.1-rc.2", ">=0.2.0-rc.2 <0.3.0-0", ">=0.2.0-rc.2 <0.3.0"]
137
+ for (const range of ranges) {
138
+ for (const version of versions) {
139
+ expect(
140
+ satisfiesRange(version, range),
141
+ `oracle mismatch: ${version} vs ${range} (real semver says ${semver.satisfies(version, range, opts) ? "IN" : "out"})`,
142
+ ).toBe(semver.satisfies(version, range, opts))
143
+ }
144
+ }
145
+ })
146
+
147
+ /**
148
+ * @test REQ-DSH-017
149
+ * @intent 验证 peerDependencies 覆盖 DSH 实际运行时版本 0.2.0-rc.2:
150
+ * 旧的 ^0.1.1-rc.2 在 0.x 语义下只放行 patch,导致 dsh plugin add
151
+ * 被 evaluatePluginCompatibility 判定不兼容并回滚安装。
152
+ * @covers AC-DSH-017-01
153
+ */
154
+ it("declares peer ranges that admit the DSH 0.2.x runtime", () => {
155
+ const pkg = readPkg()
156
+ const peers = pkg.peerDependencies ?? {}
157
+ for (const name of DSH_PEERS) {
158
+ const range = peers[name]
159
+ expect(range, `${name} must declare a peer range`).toBeTypeOf("string")
160
+ expect(
161
+ satisfiesRange("0.2.0-rc.2", range as string),
162
+ `${name} range ${range} must admit the running DSH 0.2.0-rc.2`,
163
+ ).toBe(true)
164
+ }
165
+ })
166
+
167
+ /**
168
+ * @test REQ-DSH-017
169
+ * @intent 验证上界真正封死 0.3.0 线。DSH 以 includePrerelease 语义比较,
170
+ * 裸 `<0.3.0` 会把 0.3.0-rc.1 放行(预发布排在正式版之前),
171
+ * 因此必须使用 `<0.3.0-0` 哨兵。这是边界值回归,不是风格偏好。
172
+ * @covers AC-DSH-017-02
173
+ */
174
+ it("excludes the whole 0.3.0 line, prereleases included", () => {
175
+ const pkg = readPkg()
176
+ const peers = pkg.peerDependencies ?? {}
177
+ for (const name of DSH_PEERS) {
178
+ const range = peers[name] as string
179
+ expect(satisfiesRange("0.3.0-rc.1", range), `${name}: 0.3.0-rc.1 must be rejected by ${range}`).toBe(false)
180
+ expect(satisfiesRange("0.3.0", range), `${name}: 0.3.0 must be rejected by ${range}`).toBe(false)
181
+ }
182
+ })
183
+
184
+ /**
185
+ * @test REQ-DSH-017
186
+ * @intent 验证下界保持为 DSH 0.2.x——0.2.1/0.2.9 这类后续 patch 仍可用,
187
+ * 而 0.1.x 被排除(下界必须如实反映实测过的运行时)。
188
+ * @covers AC-DSH-017-03
189
+ */
190
+ it("admits later 0.2.x patches and rejects 0.1.x", () => {
191
+ const pkg = readPkg()
192
+ const peers = pkg.peerDependencies ?? {}
193
+ for (const name of DSH_PEERS) {
194
+ const range = peers[name] as string
195
+ expect(satisfiesRange("0.2.1", range), `${name}: 0.2.1 must be admitted`).toBe(true)
196
+ expect(satisfiesRange("0.2.9", range), `${name}: 0.2.9 must be admitted`).toBe(true)
197
+ expect(satisfiesRange("0.1.1-rc.2", range), `${name}: 0.1.x must be rejected`).toBe(false)
198
+ }
199
+ })
200
+ })
@@ -14,7 +14,12 @@ interface PkgShape {
14
14
  }
15
15
 
16
16
  function readPkg(): PkgShape {
17
- return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")) as PkgShape
17
+ const path = join(PKG_ROOT, "package.json")
18
+ try {
19
+ return JSON.parse(readFileSync(path, "utf8")) as PkgShape
20
+ } catch (error) {
21
+ throw new Error(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`)
22
+ }
18
23
  }
19
24
 
20
25
  describe("packaging contract", () => {
@@ -60,6 +65,7 @@ describe("packaging contract", () => {
60
65
  "delphi.test.ts",
61
66
  "delphi-config.test.ts",
62
67
  "delphi-run.test.ts",
68
+ "compat.test.ts",
63
69
  ]
64
70
  for (const f of files) {
65
71
  const content = readFileSync(join(PKG_ROOT, "src", f), "utf8")
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @test REQ-DSH-014 Delphi credential seam
3
+ * @intent Verify the plugin can obtain the Delphi API key from DSH's credential store
4
+ * when the process environment does not carry it, and that resolution is
5
+ * single-layer — a store miss must not silently fall back to process.env.
6
+ * @covers AC-DSH-014-01 AC-DSH-014-02 AC-DSH-014-03
7
+ *
8
+ * Why this exists: the machine that motivated this release has WHALECLOUD_API_KEY in
9
+ * a user-level environment variable and in ~/.dsh/.credentials.yaml, but NOT in the
10
+ * environment DSH's own process inherits. Every gate tool worked; delphi-review alone
11
+ * failed with "missing WHALECLOUD_API_KEY". A plugin that installs, lists its skills,
12
+ * and then cannot run its headline feature is the definition of incomplete.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest"
16
+ import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials"
17
+ import { createCredentialResolver } from "./credentials.js"
18
+
19
+ describe("createCredentialResolver", () => {
20
+ it("resolves through the real CredentialRef the DSH service expects", async () => {
21
+ // Guards against hand-built reference shapes: the service takes a branded ref,
22
+ // so passing a plain object would type-check nowhere and fail at runtime.
23
+ const seen: unknown[] = []
24
+ const credentials = {
25
+ async resolve(ref: unknown) {
26
+ seen.push(ref)
27
+ return { value: "key-from-store" }
28
+ },
29
+ }
30
+ const resolve = createCredentialResolver(credentials, {}, (name) => credentialRef(name))
31
+ await expect(resolve("WHALECLOUD_API_KEY")).resolves.toBe("key-from-store")
32
+ expect(seen).toHaveLength(1)
33
+ expect(isCredentialRefName(seen[0] as string)).toBe(true)
34
+ expect(seen[0]).toBe(credentialRef("WHALECLOUD_API_KEY"))
35
+ })
36
+ it("returns the value stored in the DSH credential store", async () => {
37
+ const calls: unknown[] = []
38
+ const credentials = {
39
+ async resolve(ref: unknown) {
40
+ calls.push(ref)
41
+ return { value: "key-from-store" }
42
+ },
43
+ }
44
+ // Default brand is the identity, which is all a plain string-keyed fake needs.
45
+ const resolve = createCredentialResolver(credentials, {})
46
+ await expect(resolve("WHALECLOUD_API_KEY")).resolves.toBe("key-from-store")
47
+ expect(calls).toEqual(["WHALECLOUD_API_KEY"])
48
+ })
49
+
50
+ it("does not fall back to process.env when the store answers undefined", async () => {
51
+ // The store already layers env -> provider store -> .env. Re-reading env here
52
+ // would resurrect a key the store deliberately reported as absent, and would
53
+ // make a GUI session resolve differently from a terminal session.
54
+ const credentials = { resolve: async () => undefined }
55
+ const env = { WHALECLOUD_API_KEY: "key-from-env" }
56
+ const resolve = createCredentialResolver(credentials, env)
57
+ await expect(resolve("WHALECLOUD_API_KEY")).resolves.toBeUndefined()
58
+ })
59
+
60
+ it("uses process env only when no credential service is available", async () => {
61
+ const resolve = createCredentialResolver(undefined, { WHALECLOUD_API_KEY: "key-from-env" })
62
+ await expect(resolve("WHALECLOUD_API_KEY")).resolves.toBe("key-from-env")
63
+ })
64
+
65
+ it("returns undefined when neither source has the value", async () => {
66
+ const resolve = createCredentialResolver(undefined, {})
67
+ await expect(resolve("WHALECLOUD_API_KEY")).resolves.toBeUndefined()
68
+ })
69
+
70
+ it("ignores names that are not valid environment-variable identifiers", async () => {
71
+ const credentials = {
72
+ resolve: async () => {
73
+ throw new Error("must not be called for an invalid ref")
74
+ },
75
+ }
76
+ const resolve = createCredentialResolver(credentials, {})
77
+ await expect(resolve("../etc/passwd")).resolves.toBeUndefined()
78
+ await expect(resolve("")).resolves.toBeUndefined()
79
+ await expect(resolve("has-dash")).resolves.toBeUndefined()
80
+ })
81
+
82
+ it("degrades to undefined when the credential store throws", async () => {
83
+ // A broken store must not take the whole tool down; the caller reports a
84
+ // named setup error instead.
85
+ const credentials = {
86
+ resolve: async () => {
87
+ throw new Error("store unavailable")
88
+ },
89
+ }
90
+ const resolve = createCredentialResolver(credentials, {})
91
+ await expect(resolve("WHALECLOUD_API_KEY")).resolves.toBeUndefined()
92
+ })
93
+ })
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Resolve the API key that a Delphi provider config points at, without assuming the
3
+ * value is in this process's environment.
4
+ *
5
+ * The credential service shipped with DSH layers environment, a provider-managed
6
+ * store, and `.env` files behind one `CredentialRef`. That is exactly the order the
7
+ * plugin needs: on the machine that prompted this work the key lived in a user-level
8
+ * environment variable and in `~/.dsh/.credentials.yaml`, while DSH's own process
9
+ * inherited neither — so `delphi-review` failed with a missing-variable error while
10
+ * every other gate worked.
11
+ *
12
+ * Resolution is deliberately single-layer. When `ctx.credentials` is present and
13
+ * reports `undefined`, this module does NOT fall back to `process.env`: the service
14
+ * has already consulted the environment, so a second look would resurrect a value it
15
+ * deliberately reported as absent and would make a DSH session resolve differently
16
+ * from a terminal session.
17
+ */
18
+
19
+ /** A credential service, narrowed to the one method this module needs. */
20
+ export interface CredentialService {
21
+ resolve(ref: unknown): Promise<{ value: string } | undefined>
22
+ }
23
+
24
+ /** Resolve one environment-variable-style reference to its current value. */
25
+ export type ApiKeyResolver = (ref: string) => Promise<string | undefined>
26
+
27
+ /**
28
+ * Environment-variable names: a POSIX shell identifier. Anything else cannot name a
29
+ * credential, so it reads as "not set" rather than reaching the store.
30
+ */
31
+ const REFERENCE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
32
+
33
+ function isReferenceName(value: string): boolean {
34
+ return REFERENCE_NAME.test(value)
35
+ }
36
+
37
+ /**
38
+ * Build an `ApiKeyResolver` over the available credential service.
39
+ *
40
+ * @param credentials - `ctx.credentials`, or undefined when the host does not offer
41
+ * one. Absence degrades to reading the process environment directly, which keeps
42
+ * the plugin working outside DSH (CLI use, tests).
43
+ * @param env - environment to read in that fallback. Injected rather than read from
44
+ * the global so the module stays testable offline.
45
+ * @param brand - converts a raw name into the service's nominal `CredentialRef`.
46
+ * Injected so this module needs no import of the peer package and tests can pass
47
+ * an identity function.
48
+ */
49
+ export function createCredentialResolver(
50
+ credentials: CredentialService | undefined,
51
+ env: NodeJS.ProcessEnv,
52
+ brand: (value: string) => unknown = (value) => value,
53
+ ): ApiKeyResolver {
54
+ return async (ref: string): Promise<string | undefined> => {
55
+ if (!isReferenceName(ref)) return undefined
56
+
57
+ if (credentials !== undefined) {
58
+ try {
59
+ const resolved = await credentials.resolve(brand(ref))
60
+ return resolved?.value
61
+ } catch {
62
+ // A broken provider must not take the tool down; the caller turns the
63
+ // missing value into a DelphiSetupError that names the variable.
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ const value = env[ref]
69
+ return typeof value === "string" && value.length > 0 ? value : undefined
70
+ }
71
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @test REQ-DSH-007 cooperative cancellation reaches Delphi
3
+ * @intent Verify an externally aborted signal stops a Delphi review and surfaces as
4
+ * TOOL_ABORTED, the same code the three gate tools already raise on cancel.
5
+ * @covers AC-DSH-007-02
6
+ *
7
+ * Why this matters for cost as much as for correctness: a review is up to 5 rounds
8
+ * across 3 seats under a 900s ceiling. Without signal plumbing, pressing Stop in the
9
+ * GUI left every one of those requests running to completion — the user paid for a
10
+ * review they had already abandoned.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest"
14
+ import { TOOL_ABORTED } from "@deepseek-ai/dsh-tools"
15
+ import { runReview } from "./delphi-run.js"
16
+ import type { DelphiOptions } from "./delphi.js"
17
+
18
+ /** A fetch that never settles until the supplied signal aborts. */
19
+ function hangingFetch(signal: AbortSignal | null | undefined): typeof fetch {
20
+ return ((_url: string, init?: RequestInit) =>
21
+ new Promise((_resolve, reject) => {
22
+ const abort = () => reject(Object.assign(new Error("aborted"), { name: "AbortError" }))
23
+ const signalToWatch = init?.signal ?? signal
24
+ if (signalToWatch?.aborted) return abort()
25
+ signalToWatch?.addEventListener("abort", abort, { once: true })
26
+ return undefined
27
+ })) as unknown as typeof fetch
28
+ }
29
+
30
+ const options: DelphiOptions = {
31
+ baseUrl: "https://gw.example/v1",
32
+ apiKey: "test-key",
33
+ seats: [
34
+ { role: "architecture", model: "model-a" },
35
+ { role: "technical", model: "model-b" },
36
+ { role: "feasibility", model: "model-c" },
37
+ ] as unknown as DelphiOptions["seats"],
38
+ thresholdPercent: Number("90"),
39
+ maxRounds: Number("5"),
40
+ }
41
+
42
+ describe("runReview cancellation", () => {
43
+ it("raises TOOL_ABORTED when the caller's signal is already aborted", async () => {
44
+ const controller = new AbortController()
45
+ controller.abort()
46
+ const error = await runReview(
47
+ { ...options, fetchImpl: hangingFetch(undefined) },
48
+ "a diff",
49
+ "",
50
+ controller.signal,
51
+ ).catch((e: unknown) => e)
52
+
53
+ expect(error).toBeInstanceOf(Error)
54
+ // The harness maps cancellation to this code; anything else reads as a failure.
55
+ expect((error as { code?: string }).code).toBe(TOOL_ABORTED)
56
+ })
57
+
58
+ it("stops an in-flight review when the signal aborts mid-round", async () => {
59
+ const controller = new AbortController()
60
+ const pending = runReview(
61
+ { ...options, fetchImpl: hangingFetch(undefined) },
62
+ "a diff",
63
+ "",
64
+ controller.signal,
65
+ ).catch((e: unknown) => e)
66
+ // Abort after the request is in flight, not before.
67
+ controller.abort()
68
+ const error = await pending
69
+ expect((error as { code?: string }).code).toBe(TOOL_ABORTED)
70
+ })
71
+ })
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @test REQ-DSH-014 apiKey override on the Delphi config loader
3
+ * @intent Verify loadDelphiConfig accepts a pre-resolved apiKey and uses it in place of
4
+ * resolving ${VAR} from the environment, so the plugin can feed it a value from
5
+ * DSH's credential store, and verify the override is scoped to the one name it
6
+ * was resolved for.
7
+ * @covers AC-DSH-014-04 AC-DSH-014-05
8
+ *
9
+ * Why: the loader is synchronous and the credential service is asynchronous, so the
10
+ * value has to be resolved by the caller and handed in. Without this seam the only way
11
+ * to supply a key is an environment variable — precisely what the DSH process lacks.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest"
15
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
16
+ import { join } from "node:path"
17
+ import { tmpdir } from "node:os"
18
+ import { loadDelphiConfig, DelphiSetupError } from "./delphi-config.js"
19
+
20
+ let dir: string
21
+
22
+ /**
23
+ * A single-gateway config body: every expert must share one base_url, so the
24
+ * provider block is built once and only its api_key varies per test.
25
+ */
26
+ function configFor(apiKeyExpression: string): Record<string, unknown> {
27
+ const provider = { base_url: "https://gw.example/v1", api_key: apiKeyExpression }
28
+ const profile = {
29
+ provider: "gw",
30
+ providers: { gw: provider },
31
+ experts: {
32
+ architecture: { provider: "gw", model: "model-a" },
33
+ technical: { provider: "gw", model: "model-b" },
34
+ feasibility: { provider: "gw", model: "model-c" },
35
+ },
36
+ }
37
+ return { active_profile: "test", profiles: { test: profile } }
38
+ }
39
+
40
+ /** Write a fixture config, surfacing any filesystem failure as a test error. */
41
+ function writeConfig(apiKeyExpression: string): void {
42
+ try {
43
+ writeFileSync(join(dir, ".delphi-config.json"), JSON.stringify(configFor(apiKeyExpression)), "utf8")
44
+ } catch (error) {
45
+ throw new Error(`cannot write fixture config: ${(error as Error).message}`)
46
+ }
47
+ }
48
+
49
+ const GLOBAL = join(tmpdir(), "xp-gate-no-such-global.json")
50
+
51
+ beforeEach(() => {
52
+ dir = mkdtempSync(join(tmpdir(), "xp-gate-delphi-override-"))
53
+ })
54
+
55
+ afterEach(() => {
56
+ rmSync(dir, { recursive: true, force: true })
57
+ })
58
+
59
+ describe("loadDelphiConfig apiKey override", () => {
60
+ it("uses the override when the environment cannot supply the key", () => {
61
+ writeConfig("${WHALECLOUD_API_KEY}")
62
+ // The DSH process has no such variable; the override stands in for it.
63
+ const resolved = loadDelphiConfig(dir, {}, GLOBAL, { WHALECLOUD_API_KEY: "resolved-from-store" })
64
+ expect(resolved.apiKey).toBe("resolved-from-store")
65
+ })
66
+
67
+ it("still fails loudly, naming the variable, when no override is given", () => {
68
+ writeConfig("${WHALECLOUD_API_KEY}")
69
+ expect(() => loadDelphiConfig(dir, {}, GLOBAL)).toThrow(DelphiSetupError)
70
+ try {
71
+ loadDelphiConfig(dir, {}, GLOBAL)
72
+ } catch (error) {
73
+ expect((error as Error).message).toContain("WHALECLOUD_API_KEY")
74
+ }
75
+ })
76
+
77
+ it("prefers an override over an environment value", () => {
78
+ // The caller resolved from the authoritative store; that decision wins.
79
+ writeConfig("${WHALECLOUD_API_KEY}")
80
+ const resolved = loadDelphiConfig(dir, { WHALECLOUD_API_KEY: "from-env" }, GLOBAL, {
81
+ WHALECLOUD_API_KEY: "from-store",
82
+ })
83
+ expect(resolved.apiKey).toBe("from-store")
84
+ })
85
+
86
+ it("leaves a literal api_key untouched by the override", () => {
87
+ // A config that hard-codes a key references no variable, so there is nothing
88
+ // for a variable-scoped override to replace.
89
+ writeConfig("literal-key-value")
90
+ const resolved = loadDelphiConfig(dir, {}, GLOBAL, { WHALECLOUD_API_KEY: "resolved-from-store" })
91
+ expect(resolved.apiKey).toBe("literal-key-value")
92
+ })
93
+ })
94
+
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @test REQ-DSH-014 credential reference discovery
3
+ * @intent Verify the plugin can learn WHICH environment-variable names the Delphi
4
+ * config references, without resolving them, so it knows what to ask DSH's
5
+ * credential service for.
6
+ * @covers AC-DSH-014-06
7
+ *
8
+ * Why: loadDelphiConfig is synchronous and the credential service is asynchronous, so
9
+ * the tool must resolve first and load second. That needs a cheap way to read the
10
+ * referenced names out of the config before any value exists — otherwise the loader
11
+ * throws before the caller ever learns what to resolve.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest"
15
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
16
+ import { join } from "node:path"
17
+ import { tmpdir } from "node:os"
18
+ import { listCredentialReferences } from "./delphi-config.js"
19
+
20
+ let dir: string
21
+ const GLOBAL = join(tmpdir(), "xp-gate-no-such-global.json")
22
+
23
+ /** A single-gateway config body; only the api_key expression varies per test. */
24
+ function configFor(apiKeyExpression: string): Record<string, unknown> {
25
+ const provider = { base_url: "https://gw.example/v1", api_key: apiKeyExpression }
26
+ const profile = {
27
+ provider: "gw",
28
+ providers: { gw: provider },
29
+ experts: {
30
+ architecture: { provider: "gw", model: "model-a" },
31
+ technical: { provider: "gw", model: "model-b" },
32
+ feasibility: { provider: "gw", model: "model-c" },
33
+ },
34
+ }
35
+ return { active_profile: "test", profiles: { test: profile } }
36
+ }
37
+
38
+ /** Write a fixture config, surfacing any filesystem failure as a test error. */
39
+ function writeConfig(apiKeyExpression: string): void {
40
+ try {
41
+ writeFileSync(join(dir, ".delphi-config.json"), JSON.stringify(configFor(apiKeyExpression)), "utf8")
42
+ } catch (error) {
43
+ throw new Error(`cannot write fixture config: ${(error as Error).message}`)
44
+ }
45
+ }
46
+
47
+ beforeEach(() => {
48
+ dir = mkdtempSync(join(tmpdir(), "xp-gate-creds-refs-"))
49
+ })
50
+
51
+ afterEach(() => {
52
+ rmSync(dir, { recursive: true, force: true })
53
+ })
54
+
55
+ describe("listCredentialReferences", () => {
56
+ it("names the variable a ${VAR} api_key points at", () => {
57
+ writeConfig("${WHALECLOUD_API_KEY}")
58
+ expect(listCredentialReferences(dir, GLOBAL)).toEqual(["WHALECLOUD_API_KEY"])
59
+ })
60
+
61
+ it("returns nothing for a literal api_key", () => {
62
+ writeConfig("literal-key-value")
63
+ expect(listCredentialReferences(dir, GLOBAL)).toEqual([])
64
+ })
65
+
66
+ it("returns the variable name even when it is unset — never throws", () => {
67
+ // This is the whole point: the caller needs the name in order to resolve it.
68
+ writeConfig("${SOME_UNSET_KEY}")
69
+ expect(listCredentialReferences(dir, GLOBAL)).toEqual(["SOME_UNSET_KEY"])
70
+ })
71
+
72
+ it("returns an empty list when no config exists at all", () => {
73
+ expect(listCredentialReferences(dir, GLOBAL)).toEqual([])
74
+ })
75
+ })