@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,123 @@
1
+ /**
2
+ * @test REQ-DSH-011 tri-state gate rendering
3
+ * @intent Verify renderGateResult classifies every shell outcome into exactly one of
4
+ * PASS / GATE FAILED / INDETERMINATE, following the documented decision order,
5
+ * and that no ambiguous outcome can be read as a pass.
6
+ * @covers AC-DSH-011-01 AC-DSH-011-02 AC-DSH-011-03 AC-DSH-011-04 AC-DSH-011-05 AC-DSH-011-06
7
+ *
8
+ * Why the ordering matters: a timed-out process can still report exit 0 from its
9
+ * last state before the kill, so checking `exit === 0` first would turn a timeout
10
+ * into a green check. The same mistake in Gate 8 and Gate 10 is exactly what this
11
+ * project kept re-fixing — a gate that could not run must never report success.
12
+ */
13
+
14
+ import { describe, it, expect } from "vitest"
15
+ import type { ShellRunResult } from "@deepseek-ai/dsh-shell"
16
+ import { renderGateResult } from "./gate-runner.js"
17
+
18
+ /** The shell's convention for "command not found" — no verdict, no reason. */
19
+ const COMMAND_NOT_FOUND = 127;
20
+
21
+ /** A non-zero count a real gate prints when it finds violations. */
22
+ const VIOLATIONS_FOUND = 3;
23
+
24
+ function makeResult(over: Partial<ShellRunResult> = {}): ShellRunResult {
25
+ return {
26
+ exitCode: 0,
27
+ stdout: { text: "", truncated: false },
28
+ stderr: { text: "", truncated: false },
29
+ timedOut: false,
30
+ timeoutMs: 1000,
31
+ signal: null,
32
+ aborted: false,
33
+ ...over,
34
+ } as ShellRunResult
35
+ }
36
+
37
+ describe("renderGateResult tri-state", () => {
38
+ it("reports PASS on exit 0 with non-empty stdout", () => {
39
+ const out = renderGateResult(makeResult({ stdout: { text: "all gates ok", truncated: false } }))
40
+ expect(out).toContain("[XP-Gate] PASS")
41
+ expect(out).toContain("all gates ok")
42
+ expect(out).not.toContain("INDETERMINATE")
43
+ expect(out).not.toContain("GATE FAILED")
44
+ })
45
+
46
+ it("reports INDETERMINATE on exit 0 with empty stdout", () => {
47
+ // The command succeeded but said nothing — that is not a pass.
48
+ const out = renderGateResult(makeResult({ stdout: { text: " \n", truncated: false } }))
49
+ expect(out).toContain("INDETERMINATE")
50
+ expect(out).toContain("NOT a pass")
51
+ expect(out).not.toContain("[XP-Gate] PASS")
52
+ })
53
+
54
+ it("reports GATE FAILED on nonzero exit with output", () => {
55
+ const out = renderGateResult(
56
+ makeResult({
57
+ exitCode: VIOLATIONS_FOUND,
58
+ stdout: { text: `${VIOLATIONS_FOUND} violations found`, truncated: false },
59
+ }),
60
+ )
61
+ expect(out).toContain("GATE FAILED")
62
+ expect(out).toContain(`exit code: ${VIOLATIONS_FOUND}`)
63
+ expect(out).toContain(`${VIOLATIONS_FOUND} violations found`)
64
+ expect(out).not.toContain("INDETERMINATE")
65
+ })
66
+
67
+ it("reports GATE FAILED on nonzero exit with only stderr", () => {
68
+ const out = renderGateResult(
69
+ makeResult({
70
+ exitCode: 1,
71
+ stderr: { text: "adapter crashed", truncated: false },
72
+ }),
73
+ )
74
+ expect(out).toContain("GATE FAILED")
75
+ expect(out).toContain("adapter crashed")
76
+ })
77
+
78
+ it("reports INDETERMINATE on nonzero exit with no output at all", () => {
79
+ // Typical command-not-found: neither a verdict nor a reason.
80
+ const out = renderGateResult(makeResult({ exitCode: COMMAND_NOT_FOUND }))
81
+ expect(out).toContain("INDETERMINATE")
82
+ expect(out).not.toContain("GATE FAILED")
83
+ expect(out).not.toContain("[XP-Gate] PASS")
84
+ })
85
+
86
+ it("reports INDETERMINATE on timeout even when exit code is 0", () => {
87
+ // The ordering trap: exit 0 must not win over timedOut.
88
+ const out = renderGateResult(
89
+ makeResult({
90
+ exitCode: 0,
91
+ timedOut: true,
92
+ stdout: { text: "partial output", truncated: false },
93
+ }),
94
+ )
95
+ expect(out).toContain("INDETERMINATE")
96
+ expect(out).toContain("timed out")
97
+ expect(out).not.toContain("[XP-Gate] PASS")
98
+ })
99
+
100
+ it("reports INDETERMINATE when stdout was truncated", () => {
101
+ const out = renderGateResult(
102
+ makeResult({
103
+ stdout: { text: "head of the report", truncated: true, spillPath: "/tmp/spill.log" },
104
+ }),
105
+ )
106
+ expect(out).toContain("INDETERMINATE")
107
+ expect(out).not.toContain("[XP-Gate] PASS")
108
+ })
109
+
110
+ it("never emits PASS anywhere in an ambiguous result", () => {
111
+ const ambiguous = [
112
+ makeResult({ exitCode: 0 }),
113
+ makeResult({ exitCode: COMMAND_NOT_FOUND }),
114
+ makeResult({ timedOut: true, exitCode: 0 }),
115
+ makeResult({ stdout: { text: "", truncated: true } }),
116
+ ]
117
+ for (const result of ambiguous) {
118
+ const out = renderGateResult(result)
119
+ // "NOT a pass" is allowed; a bare verdict line is not.
120
+ expect(out).not.toMatch(/\[XP-Gate\]\s+PASS/)
121
+ }
122
+ })
123
+ })
@@ -98,51 +98,113 @@ function getLocalXpGateVersion(): string | null {
98
98
  }
99
99
  }
100
100
 
101
- async function checkXpGateUpdate(): Promise<UpgradeResult> {
102
- const cached = readCache(XP_GATE_CACHE_FILE)
101
+ /**
102
+ * Decide whether an upgrade is worth attempting, without performing one.
103
+ *
104
+ * Split out of `checkXpGateUpdate` (CCN 14). That function interleaved five
105
+ * "return noop for this reason" branches with the install itself, so the
106
+ * conditions that STOP an upgrade and the act of upgrading were one readable
107
+ * blob. Separating them means the decision can be read (and tested) on its own.
108
+ */
109
+ type UpgradeDecision =
110
+ | { kind: "noop"; localVersion: string | null; remoteVersion: string | null }
111
+ | { kind: "install"; localVersion: string; remoteVersion: string }
112
+
113
+ /**
114
+ * Is the cached answer still a truthful description of this install?
115
+ *
116
+ * A cache records the version it was computed FOR. After a manual upgrade the
117
+ * local version differs, so the cache describes a different install and must be
118
+ * re-derived -- otherwise a user who upgrades by hand is never checked again.
119
+ */
120
+ function cacheStillDescribes(
121
+ cached: { remoteVersion: string; localVersion?: string; status?: string } | null,
122
+ localVersion: string | null,
123
+ ): cached is { remoteVersion: string; localVersion: string; status: "current" } {
124
+ if (!cached) return false
125
+ if (cached.status !== "current") return false
126
+ if (!cached.remoteVersion || !localVersion) return false
127
+ return cached.localVersion === localVersion
128
+ }
129
+
130
+ async function decideUpgrade(): Promise<UpgradeDecision> {
103
131
  const localVersion = getLocalXpGateVersion()
132
+ const cached = readCache(XP_GATE_CACHE_FILE)
104
133
 
105
- // If cache exists and local version hasn't changed AND status is "current",
106
- // the cache is still valid — skip network check
107
- if (cached?.status === "current" && cached.remoteVersion && localVersion && cached.localVersion === localVersion) {
108
- return { action: "noop", localVersion, remoteVersion: cached.remoteVersion }
134
+ if (cacheStillDescribes(cached, localVersion)) {
135
+ return { kind: "noop", localVersion, remoteVersion: cached.remoteVersion }
109
136
  }
110
137
 
111
- // If we have a valid cache but local version changed (e.g., manual upgrade)
112
- // or cache has no "current" status, we need to re-check remote
113
- if (!localVersion) return { action: "noop", localVersion: null, remoteVersion: null }
138
+ if (!localVersion) return { kind: "noop", localVersion: null, remoteVersion: null }
114
139
 
115
140
  const remoteVersion = await fetchNpmLatestVersion(XP_GATE_REGISTRY_URL)
116
- if (!remoteVersion) return { action: "noop", localVersion, remoteVersion: null }
117
-
118
- if (!semverLt(localVersion, remoteVersion)) {
141
+ if (!remoteVersion) return { kind: "noop", localVersion, remoteVersion: null }
142
+
143
+ const upToDate = !semverLt(localVersion, remoteVersion)
144
+ // The opt-out is checked AFTER the version comparison so that recording
145
+ // "current" is still correct for an opted-out user; skipping it would make the
146
+ // next run re-query the registry every time.
147
+ const optedOut = readXpGateConfig()?.autoUpgrade === false
148
+ if (upToDate || optedOut) {
119
149
  writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
120
- return { action: "noop", localVersion, remoteVersion }
150
+ return { kind: "noop", localVersion, remoteVersion }
121
151
  }
122
152
 
123
- // Check opt-out config
124
- const config = readXpGateConfig()
125
- if (config?.autoUpgrade === false) {
126
- writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
127
- return { action: "noop", localVersion, remoteVersion }
128
- }
153
+ return { kind: "install", localVersion, remoteVersion }
154
+ }
129
155
 
130
- writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion })
131
- try {
132
- const exitCode = await new Promise<number | null>((resolve, reject) => {
133
- const child = spawn("npm", ["install", "-g", `${XP_GATE_NPM_PKG}@${remoteVersion}`], {
156
+ /**
157
+ * Run `npm install -g` for one version.
158
+ *
159
+ * Resolves with the exit code rather than throwing on a non-zero exit: a failed
160
+ * install is a reported condition ("error"), not an exception the caller has to
161
+ * guess the meaning of. A spawn that could not start at all still rejects -- that
162
+ * is a different failure (no npm on PATH) and the caller must see it.
163
+ *
164
+ * The spawn call is guarded as well as listening for the 'error' event: both are
165
+ * reachable (the event covers an async ENOENT, the guard covers a synchronous
166
+ * throw such as an invalid argument), and an unhandled one here would escape into
167
+ * the host's chat loop.
168
+ */
169
+ function installGlobally(version: string): Promise<number | null> {
170
+ return new Promise<number | null>((resolve, reject) => {
171
+ let child;
172
+ try {
173
+ child = spawn("npm", ["install", "-g", `${XP_GATE_NPM_PKG}@${version}`], {
134
174
  stdio: "pipe",
135
175
  shell: true, // Required on Windows: npm is a .cmd file
136
176
  timeout: 120_000,
137
177
  })
138
- child.on("close", (code) => resolve(code))
139
- child.on("error", (err) => reject(err))
140
- })
141
- if (exitCode === 0) {
142
- writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion: remoteVersion, remoteVersion, status: "current" })
143
- return { action: "upgraded", localVersion, remoteVersion }
178
+ } catch (err) {
179
+ reject(err)
180
+ return
181
+ }
182
+ child.on("close", (code) => resolve(code))
183
+ child.on("error", (err) => reject(err))
184
+ })
185
+ }
186
+
187
+ async function checkXpGateUpdate(): Promise<UpgradeResult> {
188
+ const decision = await decideUpgrade()
189
+ if (decision.kind === "noop") {
190
+ return {
191
+ action: "noop",
192
+ localVersion: decision.localVersion,
193
+ remoteVersion: decision.remoteVersion,
194
+ }
195
+ }
196
+
197
+ const { localVersion, remoteVersion } = decision
198
+ writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion })
199
+ try {
200
+ const exitCode = await installGlobally(remoteVersion)
201
+ if (exitCode !== 0) {
202
+ return { action: "error", localVersion, remoteVersion, error: `npm install exited with code ${exitCode}` }
144
203
  }
145
- return { action: "error", localVersion, remoteVersion, error: `npm install exited with code ${exitCode}` }
204
+ // Record the NEW version as local: the install succeeded, so the next run
205
+ // must not offer the same upgrade again.
206
+ writeCache(XP_GATE_CACHE_FILE, { ts: Date.now(), localVersion: remoteVersion, remoteVersion, status: "current" })
207
+ return { action: "upgraded", localVersion, remoteVersion }
146
208
  } catch (err) {
147
209
  const msg = err instanceof Error ? err.message : String(err)
148
210
  return { action: "error", localVersion, remoteVersion, error: msg }
@@ -160,26 +222,36 @@ function readXpGateConfig(): { autoUpgrade?: boolean } | null {
160
222
  }
161
223
  // ── OpenCode plugin version check (notification only) ──
162
224
 
163
- async function checkPluginUpdate(pluginDir: string): Promise<void> {
164
- const cached = readCache(OPENCODE_CACHE_FILE)
165
- if (cached?.status === "current" && cached.remoteVersion) return
166
-
167
- let localVersion = ""
225
+ /** The plugin's own installed version, or null when it cannot be read. */
226
+ function readInstalledPluginVersion(pluginDir: string): string | null {
168
227
  try {
169
228
  const pkg = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8"))
170
- localVersion = pkg.version || ""
229
+ return pkg.version || null
171
230
  } catch {
172
- return
231
+ return null
173
232
  }
233
+ }
234
+
235
+ async function checkPluginUpdate(pluginDir: string): Promise<void> {
236
+ const cached = readCache(OPENCODE_CACHE_FILE)
237
+ if (cached?.status === "current" && cached.remoteVersion) return
238
+
239
+ const localVersion = readInstalledPluginVersion(pluginDir)
240
+ if (!localVersion) return
174
241
 
175
242
  const remoteVersion = await fetchNpmLatestVersion(OPENCODE_PLUGIN_REGISTRY)
176
243
  if (!remoteVersion) return
177
244
 
178
- if (remoteVersion && localVersion && semverLt(localVersion, remoteVersion)) {
179
- writeCache(OPENCODE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion })
180
- } else if (remoteVersion && localVersion) {
181
- writeCache(OPENCODE_CACHE_FILE, { ts: Date.now(), localVersion, remoteVersion, status: "current" })
245
+ // The presence or absence of `status: "current"` is the whole message: with it
246
+ // the caller stays quiet, without it a notice is owed. It used to be expressed
247
+ // as an if/else-if that re-tested `remoteVersion && localVersion` in both arms,
248
+ // even though both were already known non-empty by this point.
249
+ const upToDate = !semverLt(localVersion, remoteVersion)
250
+ const entry: { ts: number; localVersion: string; remoteVersion: string; status?: string } = {
251
+ ts: Date.now(), localVersion, remoteVersion,
182
252
  }
253
+ if (upToDate) entry.status = "current"
254
+ writeCache(OPENCODE_CACHE_FILE, entry)
183
255
  }
184
256
 
185
257
  // ── TUI upgrade notice ──
@@ -432,39 +504,75 @@ function writeSessionModel(dbPath: string, sessionId: string, modelJson: string)
432
504
  // Boy Scout (#447): the tool's execute() body inlined 40+ lines at 5 brace
433
505
  // levels (tool → execute → try → JSON.stringify options). The logic lives at
434
506
  // module scope; execute() is pure wiring.
435
- function reloadSessionModel(sessionId: string): string {
507
+ /**
508
+ * The config a session-model reload needs, or the reason it cannot proceed.
509
+ *
510
+ * Split out of `reloadSessionModel` (CCN 14): the function was six preconditions
511
+ * plus one action, and each precondition owns a distinct diagnostic. Keeping them
512
+ * as one linear chain made the failure paths hard to read and impossible to test
513
+ * individually; returning a discriminated result lets the caller stay flat.
514
+ */
515
+ type ReloadInputs =
516
+ | { ok: true; dbPath: string; providerID: string; modelID: string }
517
+ | { ok: false; reason: string }
518
+
519
+ function readReloadInputs(): ReloadInputs {
436
520
  const dbPath = join(homedir(), ".local", "share", "opencode", "opencode.db")
437
- const configDir = join(homedir(), ".config", "opencode")
438
- const omoConfigPath = join(configDir, "oh-my-openagent.json")
439
-
440
521
  if (!existsSync(dbPath)) {
441
- return "Session reload failed: OpenCode database not found at " + dbPath
522
+ return { ok: false, reason: "Session reload failed: OpenCode database not found at " + dbPath }
442
523
  }
524
+
525
+ const omoConfigPath = join(homedir(), ".config", "opencode", "oh-my-openagent.json")
443
526
  if (!existsSync(omoConfigPath)) {
444
- return "Session reload failed: oh-my-openagent.json not found at " + omoConfigPath
527
+ return { ok: false, reason: "Session reload failed: oh-my-openagent.json not found at " + omoConfigPath }
445
528
  }
446
529
 
447
- let omoConfig: Record<string, unknown>
530
+ const parsed = parseOmoConfig(omoConfigPath)
531
+ if (typeof parsed === "string") return { ok: false, reason: parsed }
532
+
533
+ const model = readSisyphusModel(parsed)
534
+ if (typeof model === "string" && model.startsWith("Session reload failed")) {
535
+ return { ok: false, reason: model }
536
+ }
537
+
538
+ const slashIdx = (model as string).indexOf("/")
539
+ if (slashIdx === -1) {
540
+ return { ok: false, reason: `Session reload failed: model "${model}" is not in provider/model format` }
541
+ }
542
+
543
+ return {
544
+ ok: true,
545
+ dbPath,
546
+ providerID: (model as string).substring(0, slashIdx),
547
+ modelID: (model as string).substring(slashIdx + 1),
548
+ }
549
+ }
550
+
551
+ /** The parsed config object, or the failure message. */
552
+ function parseOmoConfig(path: string): Record<string, unknown> | string {
448
553
  try {
449
- omoConfig = JSON.parse(readFileSync(omoConfigPath, "utf8"))
554
+ return JSON.parse(readFileSync(path, "utf8"))
450
555
  } catch (err) {
451
556
  const msg = err instanceof Error ? err.message : String(err)
452
557
  return "Session reload failed: could not parse oh-my-openagent.json: " + msg
453
558
  }
559
+ }
454
560
 
455
- const agents = omoConfig?.["agents"] as Record<string, { model?: string }> | undefined
456
- const sisyphusModel = agents?.["sisyphus"]?.["model"]
457
- if (!sisyphusModel || typeof sisyphusModel !== "string") {
561
+ /** The configured sisyphus model id, or the failure message. */
562
+ function readSisyphusModel(config: Record<string, unknown>): string {
563
+ const agents = config?.["agents"] as Record<string, { model?: string }> | undefined
564
+ const model = agents?.["sisyphus"]?.["model"]
565
+ if (!model || typeof model !== "string") {
458
566
  return "Session reload failed: could not find sisyphus.model in oh-my-openagent.json"
459
567
  }
568
+ return model
569
+ }
460
570
 
461
- const slashIdx = sisyphusModel.indexOf("/")
462
- if (slashIdx === -1) {
463
- return `Session reload failed: model "${sisyphusModel}" is not in provider/model format`
464
- }
465
- const providerID = sisyphusModel.substring(0, slashIdx)
466
- const modelID = sisyphusModel.substring(slashIdx + 1)
571
+ function reloadSessionModel(sessionId: string): string {
572
+ const inputs = readReloadInputs()
573
+ if (!inputs.ok) return inputs.reason
467
574
 
575
+ const { dbPath, providerID, modelID } = inputs
468
576
  const oldModel = readCurrentSessionModel(dbPath, sessionId)
469
577
  try {
470
578
  writeSessionModel(dbPath, sessionId, JSON.stringify({ id: modelID, providerID }))
@@ -557,4 +665,30 @@ const pluginModule = {
557
665
  server: XpGatePlugin,
558
666
  }
559
667
 
668
+ /**
669
+ * Internals exposed for the test suite.
670
+ *
671
+ * This module had 560 lines and 27 functions with no tests at all, so nothing
672
+ * could be changed safely -- including the CCN=48 update callback. The pure
673
+ * helpers are re-exported here so their behaviour can be pinned (REQ-625)
674
+ * before and after the refactor that brings the complexity down.
675
+ *
676
+ * Not part of the plugin's public surface: the host loads `default`, and nothing
677
+ * here is a tool or a hook.
678
+ */
679
+ export const __test = {
680
+ semverLt,
681
+ readCache,
682
+ writeCache,
683
+ extractTitleFromText,
684
+ getLocalXpGateVersion,
685
+ checkXpGateUpdate,
686
+ checkPluginUpdate,
687
+ runBackgroundUpdates,
688
+ reloadSessionModel,
689
+ renameSession,
690
+ XP_GATE_CACHE_FILE,
691
+ OPENCODE_CACHE_FILE,
692
+ }
693
+
560
694
  export default pluginModule
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/opencode-plugin",
3
- "version": "0.31.4",
3
+ "version": "0.32.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "XP-Gate quality gates + AI workflow skills + Sprint Flow TUI sidebar for OpenCode",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.31.4",
3
+ "version": "0.32.1",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Qoder. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,9 +1,9 @@
1
1
  # PRINCIPLES CHECKER MODULE
2
2
 
3
- **Generated:** 2026-10-26
4
- **Commit:** dfe3bf53
3
+ **Generated:** 2026-10-29
4
+ **Commit:** 101a0ff3
5
5
  **Branch:** main
6
- **Version:** 0.31.4.0
6
+ **Version:** 0.32.1.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Clean Code & SOLID principles checker — **Gate 4** of pre-commit. 14 rules × 9 language adapters, SARIF 2.1.0 output. Houses the **Boy Scout Rule** enforcement engine (Gate 6) and warning-baseline storage.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @test REQ-623
3
+ * @intent A file the checker could not read must not be silently rounded down to
4
+ * "no violations". Before this, `analyze` caught the read failure into
5
+ * `result.errors` and `main()` never looked at that array, so the run
6
+ * exited 0 with a clean report. That is how CI's SARIF step archived a
7
+ * successful-looking report while its log said
8
+ * `Analysis failed: Could not read file: <blob>` -- a report about fewer
9
+ * files than were asked for, presented as a report about all of them.
10
+ *
11
+ * The contract kept here: an unreadable file is RECORDED (one entry, not
12
+ * one per rule) and `main()` refuses to call the run clean.
13
+ * @covers AC-623-04, AC-623-05
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { analyze, getAdapterForFile } from '../analyzer';
21
+ import { getAllRules } from '../index';
22
+
23
+ /** A directory named `*.ts`: resolves a TS adapter, then cannot be read. */
24
+ function unreadableTsPath(): string {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-analyze-'));
26
+ const asDir = path.join(dir, 'broken.ts');
27
+ fs.mkdirSync(asDir);
28
+ return asDir;
29
+ }
30
+
31
+ describe('AC-623-04: an unreadable file is recorded, not swallowed', () => {
32
+ it('puts the read failure in result.errors', async () => {
33
+ const result = await analyze([unreadableTsPath()], getAllRules(), getAdapterForFile);
34
+ expect(result.errors.length).toBeGreaterThan(0);
35
+ });
36
+
37
+ it('names the file that could not be read', async () => {
38
+ const target = unreadableTsPath();
39
+ const result = await analyze([target], getAllRules(), getAdapterForFile);
40
+ expect(result.errors.join('\n')).toContain('broken.ts');
41
+ });
42
+
43
+ it('records one entry per file, not one per rule', async () => {
44
+ // The unreadable file is touched by every enabled rule; the failure is a
45
+ // property of the file, so the operator must not see it repeated per rule.
46
+ const result = await analyze([unreadableTsPath()], getAllRules(), getAdapterForFile);
47
+ expect(result.errors.length).toBe(1);
48
+ });
49
+
50
+ it('does not report the unreadable file as examined-and-clean', async () => {
51
+ const result = await analyze([unreadableTsPath()], getAllRules(), getAdapterForFile);
52
+ // No violations could have been found in a file that was never read.
53
+ expect(result.violations).toHaveLength(0);
54
+ // ...so the only honest signal is the error, and it must be present.
55
+ expect(result.errors.length).toBeGreaterThan(0);
56
+ });
57
+ });
58
+
59
+ describe('AC-623-05: a healthy run records no errors', () => {
60
+ it('leaves errors empty when every file reads', async () => {
61
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-analyze-ok-'));
62
+ const good = path.join(dir, 'good.ts');
63
+ fs.writeFileSync(good, 'export const value = 1;\n', 'utf8');
64
+
65
+ const result = await analyze([good], getAllRules(), getAdapterForFile);
66
+
67
+ expect(result.errors).toHaveLength(0);
68
+ expect(result.summary.filesChecked).toBe(1);
69
+ });
70
+ });