@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,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
|
-
|
|
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
|
+
})
|