@boyingliu01/xp-gate 0.35.18 → 0.35.20

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 (47) hide show
  1. package/adapters/gate-3.sh +94 -9
  2. package/adapters/gate-4.sh +27 -32
  3. package/adapters/gate-8.sh +32 -3
  4. package/adapters/gate-9.sh +14 -2
  5. package/hooks/pre-commit +58 -12
  6. package/lib/__tests__/doctor-delphi.test.js +114 -0
  7. package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
  8. package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
  9. package/lib/__tests__/sprint-status-rework.test.js +32 -7
  10. package/lib/__tests__/test-alignment-java-702.test.ts +182 -0
  11. package/lib/__tests__/test-alignment.test.ts +6 -2
  12. package/lib/__tests__/update-skill.test.js +77 -0
  13. package/lib/doctor-tui.js +23 -2
  14. package/lib/doctor.js +28 -7
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/lib/sprint-status.js +91 -26
  17. package/lib/test-alignment.ts +152 -6
  18. package/lib/update-skill.js +59 -3
  19. package/mock-policy/AGENTS.md +3 -3
  20. package/mutation/AGENTS.md +3 -3
  21. package/package.json +1 -1
  22. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  23. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/dsh/lib/index.d.ts +24 -0
  27. package/plugins/dsh/lib/index.d.ts.map +1 -1
  28. package/plugins/dsh/lib/index.js +37 -1
  29. package/plugins/dsh/package.json +1 -1
  30. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/plugins/dsh/src/delphi-version-footer.test.ts +42 -0
  34. package/plugins/dsh/src/index.ts +38 -1
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/principles/AGENTS.md +3 -3
  44. package/scripts/sync-package-content.js +32 -1
  45. package/skills/delphi-review/AGENTS.md +3 -3
  46. package/skills/sprint-flow/AGENTS.md +3 -3
  47. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @test REQ-704 Sprint Status 返工率
3
+ * @intent `--rework-check` 的返工率恒为 0,且会把任何正确的值覆盖成 0。
4
+ *
5
+ * 根因有两层,必须同时修好才算真的修好:
6
+ * (1) 统计区间的下界用了 `completed_at` —— 按定义它是 sprint **结束**的
7
+ * 时刻,而 sprint 的全部提交都发生在它之前。于是 `git log --since
8
+ * <completed_at>` 返回空集。要统计的区间是「开始 → 结束」,
9
+ * 实际统计的区间是「结束 → 现在」,恰好是空的。
10
+ * (2) `total_sprint_commits` 从未被计算过,只被读取(值恒为 0),
11
+ * 再经 `Math.max(total, 1)` 兜底成 1,使 `0/1` 恒等于 0。
12
+ *
13
+ * 这是一个质量指标的 fail-open:返工率是 #369 用来暴露「门禁全过但仍
14
+ * 返工」的唯一信号,恒为 0 意味着返工永远不可观测,而 CLI 还会主动打印
15
+ * 「All sprints within acceptable rework rate」。告警阈值永不触发。
16
+ *
17
+ * 测试必须用真实 git 仓库:这个 bug 的全部性质都藏在 `git log --since`
18
+ * 的行为里,任何 mock 都能让它「通过」。
19
+ * @covers AC-704-01, AC-704-02, AC-704-03, AC-704-04
20
+ */
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const { execFileSync } = require('child_process');
25
+
26
+ const sprintStatus = require('../sprint-status');
27
+
28
+ function git(cwd, args, env = {}) {
29
+ // `-c core.hooksPath=` disables hooks for this fixture. Without it, the
30
+ // temporary repo inherits this machine's global hooksPath and every fixture
31
+ // commit runs the full pre-commit suite (~60s each, and it can BLOCK on
32
+ // unrelated file-hygiene findings in .xp-gate/reports). The test asserts on
33
+ // `git log`, so hooks contribute nothing but time and flakiness.
34
+ return execFileSync('git', ['-c', 'core.hooksPath=', ...args], {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ env: { ...process.env, ...env },
38
+ stdio: ['pipe', 'pipe', 'pipe'],
39
+ });
40
+ }
41
+
42
+ /** Capture console.log so assertions read the CLI output, not a return value. */
43
+ function captureLogs(fn) {
44
+ const lines = [];
45
+ const original = console.log;
46
+ console.log = (...args) => lines.push(args.join(' '));
47
+ try {
48
+ const result = fn();
49
+ return { lines, result };
50
+ } finally {
51
+ console.log = original;
52
+ }
53
+ }
54
+
55
+ describe('sprint-status --rework-check 返工率 (#704)', () => {
56
+ let dir;
57
+ let stateFile;
58
+
59
+ beforeEach(() => {
60
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rework-704-'));
61
+ git(dir, ['init', '-q', '.']);
62
+ git(dir, ['config', 'user.email', 'test@example.com']);
63
+ git(dir, ['config', 'user.name', 'Test']);
64
+
65
+ // 4 commits: 2 feat + 2 fix => real rework rate 50%, above the 30% threshold.
66
+ // Author/committer dates are pinned so the sprint window is deterministic.
67
+ const commits = [
68
+ ['2026-11-01T10:00:00Z', 'a.txt', 'feat: initial'],
69
+ ['2026-11-01T10:10:00Z', 'b.txt', 'fix(core): repair the thing'],
70
+ ['2026-11-01T10:20:00Z', 'c.txt', 'feat: another'],
71
+ ['2026-11-01T10:30:00Z', 'd.txt', 'fix: 修掉一个缺陷'],
72
+ ];
73
+ for (const [date, file, message] of commits) {
74
+ fs.writeFileSync(path.join(dir, file), file);
75
+ git(dir, ['add', '-A']);
76
+ git(dir, ['commit', '-q', '-m', message], {
77
+ GIT_AUTHOR_DATE: date,
78
+ GIT_COMMITTER_DATE: date,
79
+ });
80
+ }
81
+
82
+ fs.mkdirSync(path.join(dir, '.sprint-state'), { recursive: true });
83
+ stateFile = path.join(dir, '.sprint-state', 'sprint-state.json');
84
+ // completed_at is AFTER every commit — this is the real CLOSE ordering, and
85
+ // the exact input that made `--since completed_at` return an empty set.
86
+ fs.writeFileSync(stateFile, JSON.stringify({
87
+ _schema_version: 1,
88
+ id: 'sprint-2026-11-01-01',
89
+ phase: 6,
90
+ status: 'completed',
91
+ started_at: '2026-11-01T09:00:00.000Z',
92
+ metrics: { completed_at: '2026-11-01T11:00:00.000Z' },
93
+ }, null, 2));
94
+ });
95
+
96
+ afterEach(() => {
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ });
99
+
100
+ it('AC-704-01: counts fix commits between sprint start and completion, not after completion', () => {
101
+ const { lines } = captureLogs(() => sprintStatus.handleReworkCheck(dir, 3650));
102
+ const output = lines.join('\n');
103
+
104
+ // The two fix commits happened inside the sprint window. Reporting 0 means
105
+ // the lower bound is on the wrong side of the sprint.
106
+ expect(output).toMatch(/2 fix \/ 4 total commits/);
107
+ });
108
+
109
+ it('AC-704-02: reports the real rate and raises the >30% alert', () => {
110
+ const { lines } = captureLogs(() => sprintStatus.handleReworkCheck(dir, 3650));
111
+ const output = lines.join('\n');
112
+
113
+ expect(output).toMatch(/50\.0%/);
114
+ expect(output).toMatch(/exceeds 30% threshold/);
115
+ // The all-clear line must NOT be printed when a sprint is over threshold:
116
+ // that sentence is what turns a fail-open into a false reassurance.
117
+ expect(output).not.toMatch(/All sprints within acceptable rework rate/);
118
+ });
119
+
120
+ it('AC-704-03: persists the computed rate and the commit total it was computed from', () => {
121
+ captureLogs(() => sprintStatus.handleReworkCheck(dir, 3650));
122
+
123
+ const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
124
+ expect(persisted.metrics.rework_rate).toBe(0.5);
125
+ // total_sprint_commits was never computed before; the report needs it to
126
+ // stay meaningful on the next run.
127
+ expect(persisted.metrics.total_sprint_commits).toBe(4);
128
+ });
129
+
130
+ it('AC-704-04: an empty window reports 0/0, not a confident 0%', () => {
131
+ // Second sprint state: its window contains no commits at all.
132
+ fs.writeFileSync(stateFile, JSON.stringify({
133
+ _schema_version: 1,
134
+ id: 'sprint-2026-11-02-01',
135
+ phase: 6,
136
+ status: 'completed',
137
+ started_at: '2026-11-01T10:35:00.000Z',
138
+ metrics: { completed_at: '2026-11-01T12:00:00.000Z' },
139
+ }, null, 2));
140
+
141
+ const { lines } = captureLogs(() => sprintStatus.handleReworkCheck(dir, 3650));
142
+ const output = lines.join('\n');
143
+
144
+ // Zero commits in the window is NOT "0% rework" — it is "nothing to
145
+ // measure". Rendering it as 0.0% would recreate this same fail-open one
146
+ // level down.
147
+ expect(output).toMatch(/0 fix \/ 0 total commits/);
148
+ });
149
+ });
@@ -55,6 +55,21 @@ function daysAgo(n) {
55
55
  return new Date(Date.now() - n * 86400000).toISOString();
56
56
  }
57
57
 
58
+ /**
59
+ * Hour-level offset, needed to express a sprint window that actually CONTAINS
60
+ * the fixture's commits (#704).
61
+ *
62
+ * The commits are made "now", so a window built from daysAgo(9)→daysAgo(2) held
63
+ * no commits at all. The old fixtures compensated by hand-writing
64
+ * `total_sprint_commits: 10` — a number nothing in the code ever produced — which
65
+ * pinned the expected rate against a value that could only exist in the test.
66
+ * Negative = in the future; used for `completed_at`, which in the real CLOSE
67
+ * ordering is stamped after the last commit.
68
+ */
69
+ function hoursAgo(n) {
70
+ return new Date(Date.now() - n * 3600000).toISOString();
71
+ }
72
+
58
73
  beforeAll(() => {
59
74
  TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rework-test-'));
60
75
  cwdSpy = vi.spyOn(process, 'cwd');
@@ -101,9 +116,14 @@ describe('sprint-status --rework-check (#369)', () => {
101
116
  git('init', dir);
102
117
  git('config user.email "test@test.com"', dir);
103
118
  git('config user.name "Test"', dir);
119
+ // 10 commits, 2 of them fixes => 20%, below the 30% threshold.
120
+ // The count has to come from real history: the old fixture made 3 commits
121
+ // and declared 'total_sprint_commits: 10' by hand, so the expected 20%
122
+ // could only ever be produced by the fabricated number (#704).
104
123
  makeCommit('feat: initial', dir);
105
124
  makeCommit('fix: resolve login bug', dir);
106
125
  makeCommit('fix(auth): handle timeout', dir);
126
+ for (let i = 0; i < 7; i += 1) makeCommit(`feat: feature ${i}`, dir);
107
127
 
108
128
  writeSprintState({
109
129
  _schema_version: 1,
@@ -111,10 +131,10 @@ describe('sprint-status --rework-check (#369)', () => {
111
131
  task_description: 'Sprint B',
112
132
  phase: 6,
113
133
  status: 'completed',
114
- started_at: daysAgo(9),
134
+ started_at: hoursAgo(1),
115
135
  isolation: { worktree_path: null, branch: 'main' },
116
136
  phase_history: [],
117
- metrics: { completed_at: daysAgo(2), total_sprint_commits: 10 },
137
+ metrics: { completed_at: hoursAgo(-1) },
118
138
  }, dir);
119
139
 
120
140
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -142,12 +162,13 @@ describe('sprint-status --rework-check (#369)', () => {
142
162
  git('init', dir);
143
163
  git('config user.email "test@test.com"', dir);
144
164
  git('config user.name "Test"', dir);
165
+ // 10 commits, 5 of them fixes => 50%, above the 30% threshold.
145
166
  makeCommit('fix: one', dir);
146
167
  makeCommit('fix: two', dir);
147
168
  makeCommit('bugfix: three', dir);
148
169
  makeCommit('hotfix: four', dir);
149
170
  makeCommit('patch: five', dir);
150
- makeCommit('feat: six', dir);
171
+ for (let i = 0; i < 5; i += 1) makeCommit(`feat: feature ${i}`, dir);
151
172
 
152
173
  writeSprintState({
153
174
  _schema_version: 1,
@@ -155,10 +176,10 @@ describe('sprint-status --rework-check (#369)', () => {
155
176
  task_description: 'Sprint C',
156
177
  phase: 6,
157
178
  status: 'completed',
158
- started_at: daysAgo(9),
179
+ started_at: hoursAgo(1),
159
180
  isolation: { worktree_path: null, branch: 'main' },
160
181
  phase_history: [],
161
- metrics: { completed_at: daysAgo(2), total_sprint_commits: 10 },
182
+ metrics: { completed_at: hoursAgo(-1) },
162
183
  }, dir);
163
184
 
164
185
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
@@ -208,6 +229,10 @@ describe('sprint-status --rework-check (#369)', () => {
208
229
  }
209
230
  makeCommit('feat: target initial', targetDir);
210
231
  makeCommit('fix: target bug', targetDir);
232
+ // 8 more target commits => 10 total, 1 fix => 10%. The count must come from
233
+ // real history; the old fixture declared it in the state file by hand, and
234
+ // this test's actual subject is GIT_DIR precedence, not arithmetic (#704).
235
+ for (let i = 0; i < 8; i += 1) makeCommit(`feat: target feature ${i}`, targetDir);
211
236
  makeCommit('fix: outer one', outerDir);
212
237
  makeCommit('fix: outer two', outerDir);
213
238
  makeCommit('fix: outer three', outerDir);
@@ -218,10 +243,10 @@ describe('sprint-status --rework-check (#369)', () => {
218
243
  task_description: 'Hook environment sprint',
219
244
  phase: 6,
220
245
  status: 'completed',
221
- started_at: daysAgo(9),
246
+ started_at: hoursAgo(1),
222
247
  isolation: { worktree_path: null, branch: 'main' },
223
248
  phase_history: [],
224
- metrics: { completed_at: daysAgo(2), total_sprint_commits: 10 },
249
+ metrics: { completed_at: hoursAgo(-1) },
225
250
  }, targetDir);
226
251
  cwdSpy.mockReturnValue(targetDir);
227
252
 
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @test REQ-702 Test Alignment 语言覆盖
3
+ * @intent `parseTestFiles` 只认 .ts/.tsx/.js/.jsx/.py/.go。`.java` 文件被读进内存、
4
+ * 取出扩展名、然后什么都不做 —— 不是被拒绝,是被丢弃。
5
+ *
6
+ * 一个判定分支不存在时,系统报告的是「通过」而不是「未检查」:
7
+ * 纯 Java 工程 totalTests=0、totalAssertions=0,对齐分数在空集上计算,
8
+ * 但状态看起来是「跑过了」;混合工程里 Java 侧的覆盖被误报成
9
+ * 「未覆盖」,产生假阳性 gap。
10
+ *
11
+ * 与 #623(Gate 0 bypass 永不生效)、#645(真实仓库被测试写脏)同类:
12
+ * 共同点是静默失效。
13
+ *
14
+ * 修复要分三层,缺一层就还是 fail-open:
15
+ * (1) 加 .java 分支,按 JUnit 5 的实际写法解析 @test/@intent 与断言;
16
+ * (2) 未适配的扩展名必须 fail-loud,不能静默丢弃;
17
+ * (3) 已适配集合显式化,集合外的扩展名要被报出来(.kt/.cs/.rb/.rs 同理)。
18
+ * @covers AC-702-01, AC-702-02, AC-702-03, AC-702-04, AC-702-05, AC-702-06
19
+ */
20
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
21
+ import * as fs from 'fs';
22
+ import * as os from 'os';
23
+ import * as path from 'path';
24
+
25
+ import {
26
+ parseTestFiles,
27
+ UNSUPPORTED_TEST_EXTENSIONS,
28
+ runFullAlignment,
29
+ } from '../test-alignment';
30
+
31
+ const JAVA_TEST = `import org.junit.jupiter.api.Test;
32
+ import static org.junit.jupiter.api.Assertions.assertEquals;
33
+ import static org.junit.jupiter.api.Assertions.assertTrue;
34
+
35
+ /**
36
+ * @test REQ-001-01
37
+ * @intent probe intent
38
+ * @covers AC-001-01-01
39
+ */
40
+ class ProbeTest {
41
+ @Test
42
+ void shouldProbe() {
43
+ assertEquals(1, 1);
44
+ assertEquals(2, 2);
45
+ assertTrue(true);
46
+ }
47
+ }
48
+ `;
49
+
50
+ const JS_TEST = `/**
51
+ * @test REQ-001-01
52
+ * @intent probe intent
53
+ * @covers AC-001-01-01
54
+ */
55
+ describe('probe', () => {
56
+ it('should probe', () => {
57
+ expect(1).toBe(1);
58
+ });
59
+ });
60
+ `;
61
+
62
+ describe('test-alignment 语言覆盖 (#702)', () => {
63
+ let dir: string;
64
+
65
+ beforeEach(() => {
66
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'align-702-'));
67
+ });
68
+
69
+ afterEach(() => {
70
+ fs.rmSync(dir, { recursive: true, force: true });
71
+ });
72
+
73
+ it('AC-702-01: a .java test file produces a TestCase with its REQ and covers', () => {
74
+ const javaFile = path.join(dir, 'ProbeTest.java');
75
+ fs.writeFileSync(javaFile, JAVA_TEST);
76
+
77
+ const map = parseTestFiles([javaFile]);
78
+
79
+ // The file declares @test REQ-001-01 and @covers AC-001-01-01. Dropping it
80
+ // means the AC is reported "not covered by any test" — a false gap in a
81
+ // mixed project and a structurally empty report in a pure-Java one.
82
+ expect(map.totalTests).toBe(1);
83
+ expect(map.tests[0].requirementId).toBe('REQ-001-01');
84
+ expect(map.tests[0].name).toBe('shouldProbe');
85
+ expect(map.tests[0].covers).toContain('AC-001-01-01');
86
+ expect(map.tests[0].intent).toBe('probe intent');
87
+ });
88
+
89
+ it('AC-702-02: JUnit 5 assertion calls are counted', () => {
90
+ const javaFile = path.join(dir, 'ProbeTest.java');
91
+ fs.writeFileSync(javaFile, JAVA_TEST);
92
+
93
+ const map = parseTestFiles([javaFile]);
94
+
95
+ // assertEquals x2 + assertTrue x1. countAssertions() only knew JS/Python
96
+ // patterns, so a Java test would have parsed as zero-assertion.
97
+ expect(map.tests[0].assertions).toBe(3);
98
+ expect(map.totalAssertions).toBe(3);
99
+ });
100
+
101
+ it('AC-702-03: an unsupported test extension is reported, never silently dropped', () => {
102
+ const ktFile = path.join(dir, 'ProbeTest.kt');
103
+ fs.writeFileSync(ktFile, `/** @test REQ-001-01 */\nclass ProbeTest {}\n`);
104
+
105
+ const map = parseTestFiles([ktFile]);
106
+
107
+ // Silently returning an empty map is the bug: the caller cannot tell
108
+ // "no tests here" from "tests here I could not read".
109
+ expect(map.unsupportedFiles).toBeDefined();
110
+ expect(map.unsupportedFiles.length).toBe(1);
111
+ expect(map.unsupportedFiles[0]).toContain('ProbeTest.kt');
112
+ });
113
+
114
+ it('AC-702-04: the supported extension set is explicit and includes .java', () => {
115
+ // A hardcoded if/else chain is why .java fell through: there was no list to
116
+ // check the code against, so a missing branch looked like a complete one.
117
+ expect(Array.isArray(UNSUPPORTED_TEST_EXTENSIONS)).toBe(true);
118
+ expect(UNSUPPORTED_TEST_EXTENSIONS).toContain('.kt');
119
+ expect(UNSUPPORTED_TEST_EXTENSIONS).toContain('.rs');
120
+ expect(UNSUPPORTED_TEST_EXTENSIONS).not.toContain('.java');
121
+ expect(UNSUPPORTED_TEST_EXTENSIONS).not.toContain('.ts');
122
+ expect(UNSUPPORTED_TEST_EXTENSIONS).not.toContain('.go');
123
+ });
124
+ });
125
+
126
+ describe('runFullAlignment surfaces unparsed test files (#702)', () => {
127
+ const SPEC = `specification:
128
+ requirements:
129
+ - id: REQ-001-01
130
+ title: Probe requirement
131
+ acceptance_criteria:
132
+ - id: AC-001-01-01
133
+ criteria: The probe test asserts something
134
+ test_type: unit
135
+ `;
136
+
137
+ let dir: string;
138
+
139
+ beforeEach(() => {
140
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'align-full-702-'));
141
+ fs.mkdirSync(path.join(dir, 'spec'), { recursive: true });
142
+ fs.mkdirSync(path.join(dir, 'tests'), { recursive: true });
143
+ fs.writeFileSync(path.join(dir, 'spec', 'specification.yaml'), SPEC);
144
+ });
145
+
146
+ afterEach(() => {
147
+ fs.rmSync(dir, { recursive: true, force: true });
148
+ });
149
+
150
+ it('AC-702-05: a pure-Kotlin test dir cannot PASS on an empty parsed set', () => {
151
+ fs.writeFileSync(
152
+ path.join(dir, 'tests', 'ProbeTest.kt'),
153
+ '/** @test REQ-001-01 */\nclass ProbeTest {}\n',
154
+ );
155
+
156
+ const report = runFullAlignment({
157
+ specPath: path.join(dir, 'spec', 'specification.yaml'),
158
+ testDirs: [path.join(dir, 'tests')],
159
+ });
160
+
161
+ // Zero parsed tests + zero parseable files used to look like a clean run.
162
+ // It is the opposite: nothing was read, so nothing can be certified.
163
+ expect(report.alignment_status).toBe('FAIL');
164
+ expect(report.errors.some(e => e.includes('ProbeTest.kt'))).toBe(true);
165
+ expect(report.anti_pattern_detected).toBe(true);
166
+ });
167
+
168
+ it('AC-702-06: a high score does not override the presence of unparsed files', () => {
169
+ // Java test covers the AC, so the score would be high on its own.
170
+ fs.writeFileSync(path.join(dir, 'tests', 'ProbeTest.java'), JAVA_TEST);
171
+ // One unparseable Rust test is enough to make the number untrustworthy.
172
+ fs.writeFileSync(path.join(dir, 'tests', 'ProbeTest.rs'), 'fn probe() {}\n');
173
+
174
+ const report = runFullAlignment({
175
+ specPath: path.join(dir, 'spec', 'specification.yaml'),
176
+ testDirs: [path.join(dir, 'tests')],
177
+ });
178
+
179
+ expect(report.errors.some(e => e.includes('ProbeTest.rs'))).toBe(true);
180
+ expect(report.alignment_status).toBe('FAIL');
181
+ });
182
+ });
@@ -384,6 +384,7 @@ describe('REQ-ALIGN-003: verifyAlignment()', () => {
384
384
  { name: 'testA', file: 'a.test.ts', requirementId: 'REQ-A', intent: 'test A', covers: ['AC-A-01'], assertions: 2, edgeCases: [] },
385
385
  ],
386
386
  totalTests: 1,
387
+ unsupportedFiles: [],
387
388
  totalAssertions: 2,
388
389
  };
389
390
 
@@ -420,6 +421,7 @@ describe('REQ-ALIGN-003: verifyAlignment()', () => {
420
421
  }],
421
422
  totalTests: 1,
422
423
  totalAssertions: 2,
424
+ unsupportedFiles: [],
423
425
  };
424
426
 
425
427
  const report = verifyAlignment(specMap, testMap);
@@ -447,6 +449,7 @@ describe('REQ-ALIGN-003: verifyAlignment()', () => {
447
449
  }],
448
450
  totalTests: 1,
449
451
  totalAssertions: 1,
452
+ unsupportedFiles: [],
450
453
  };
451
454
 
452
455
  const report = verifyAlignment(specMap, testMap);
@@ -553,7 +556,7 @@ specification:
553
556
  const spec = parseSpecification(specFile);
554
557
  expect(spec.requirements).toHaveLength(0);
555
558
 
556
- const testMap = { tests: [], totalTests: 0, totalAssertions: 0 };
559
+ const testMap = { tests: [], totalTests: 0, totalAssertions: 0, unsupportedFiles: [] };
557
560
  const report = verifyAlignment(spec, testMap);
558
561
  // 0/0 = division by zero must be handled gracefully
559
562
  expect(report.coverage.requirementsTotal).toBe(0);
@@ -576,7 +579,7 @@ specification:
576
579
  `;
577
580
  const specFile = writeTempFile('no-tests-spec.yaml', specYaml);
578
581
  const spec = parseSpecification(specFile);
579
- const testMap = { tests: [], totalTests: 0, totalAssertions: 0 };
582
+ const testMap = { tests: [], totalTests: 0, totalAssertions: 0, unsupportedFiles: [] };
580
583
  const report = verifyAlignment(spec, testMap);
581
584
 
582
585
  expect(report.coverage.requirementsCovered).toBe(0);
@@ -747,6 +750,7 @@ specification:
747
750
  }],
748
751
  totalTests: 1,
749
752
  totalAssertions: 3,
753
+ unsupportedFiles: [],
750
754
  };
751
755
  const report = verifyAlignment(spec, testMap);
752
756
  const score = calculateScore(report.issues, report.coverage, testMap);
@@ -288,3 +288,80 @@ describe('update-skill', () => {
288
288
  });
289
289
  });
290
290
  });
291
+
292
+ // ── #682: `--all` must enumerate what is on disk, not the config registry ────
293
+ // `xp-gate init` / `setup-global` deploy skills to disk but never write
294
+ // `config.installedSkills`, so `--all` iterated an always-empty object: it
295
+ // printed "Updating all skills..." and exited 0 having touched nothing. doctor
296
+ // solved the identical problem in #635 by enumerating the filesystem; this
297
+ // block pins the same contract for update-skill, plus the honesty requirement
298
+ // that a run which updates nothing must not look like a successful one.
299
+ describe('update-skill #682 filesystem enumeration', () => {
300
+ let tmpHome;
301
+ let originalHome;
302
+ let installSkillMock;
303
+ let logSpy;
304
+ let errorSpy;
305
+
306
+ beforeEach(() => {
307
+ originalHome = process.env.HOME;
308
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-up682-'));
309
+ process.env.HOME = tmpHome;
310
+ vi.resetModules();
311
+ delete require.cache[require.resolve('../update-skill')];
312
+ delete require.cache[require.resolve('../install-skill.js')];
313
+ installSkillMock = vi.fn().mockResolvedValue(0);
314
+ const p = require.resolve('../install-skill.js');
315
+ require.cache[p] = { id: p, filename: p, loaded: true, exports: { installSkill: installSkillMock } };
316
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
317
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
318
+ });
319
+
320
+ afterEach(() => {
321
+ process.env.HOME = originalHome;
322
+ fs.rmSync(tmpHome, { recursive: true, force: true });
323
+ delete require.cache[require.resolve('../install-skill.js')];
324
+ delete require.cache[require.resolve('../update-skill')];
325
+ vi.restoreAllMocks();
326
+ });
327
+
328
+ function deploySkill(platform, name) {
329
+ const dir = path.join(tmpHome, '.config', platform, 'skills', name);
330
+ fs.mkdirSync(dir, { recursive: true });
331
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), 'old content');
332
+ return dir;
333
+ }
334
+
335
+ it('AC-682-01: --all updates skills found on disk even when config.installedSkills is absent', async () => {
336
+ deploySkill('opencode', 'sprint-flow');
337
+ deploySkill('opencode', 'delphi-review');
338
+ const { updateSkill } = require('../update-skill');
339
+ const code = await updateSkill(null, { all: true });
340
+ expect(code).toBe(0);
341
+ const names = installSkillMock.mock.calls.map((c) => c[0]);
342
+ expect(names).toContain('sprint-flow');
343
+ expect(names).toContain('delphi-review');
344
+ });
345
+
346
+ it('AC-682-02: --all does not update the same skill twice for the same platform', async () => {
347
+ deploySkill('opencode', 'sprint-flow');
348
+ fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
349
+ fs.writeFileSync(
350
+ path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
351
+ JSON.stringify({ installedSkills: { 'sprint-flow': { version: '1.0.0' } } })
352
+ );
353
+ const { updateSkill } = require('../update-skill');
354
+ await updateSkill(null, { all: true });
355
+ const sprintCalls = installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
356
+ expect(sprintCalls.length).toBe(1);
357
+ });
358
+
359
+ it('AC-682-03: --all with nothing installed says so instead of silently succeeding', async () => {
360
+ const { updateSkill } = require('../update-skill');
361
+ const code = await updateSkill(null, { all: true });
362
+ const said = [...logSpy.mock.calls, ...errorSpy.mock.calls].map((c) => c.join(' ')).join('\n');
363
+ expect(said).toMatch(/nothing to update|no skills installed|not installed/i);
364
+ expect(installSkillMock).not.toHaveBeenCalled();
365
+ expect(code).toBe(0);
366
+ });
367
+ });
package/lib/doctor-tui.js CHANGED
@@ -280,8 +280,29 @@ function diagnoseSkillsInDir(skillNames, userSkillsDir, bundledSkillsDir, checks
280
280
  });
281
281
  issues++;
282
282
  } else {
283
- // cmp >= 0: installed version is same or newer than bundled
284
- checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
283
+ // cmp >= 0: installed version is same or newer than bundled.
284
+ //
285
+ // #681: an EQUAL version is a claim, not a fact. A skill that gained a
286
+ // whole chapter without a version bump reported "Up to date" while the
287
+ // installed copy was ~20k characters behind, and `update-skill` agreed.
288
+ // So when the versions tie, still compare the bodies — and do NOT
289
+ // prescribe `update-skill` on a difference: the installed copy may be
290
+ // NEWER than main (the same asymmetry the versionless branch below
291
+ // already respects), so the advice is "compare manually".
292
+ const sameBody = sameContentIgnoringLineEndings(
293
+ withoutVersionLine(bundledContent),
294
+ withoutVersionLine(userContent)
295
+ );
296
+ if (!sameBody) {
297
+ checks.push({
298
+ name: label(name),
299
+ status: 'WARN',
300
+ detail: `Content differs at the same version ${userVersion} — the version was not bumped when the skill changed; compare manually before running update-skill`,
301
+ });
302
+ issues++;
303
+ } else {
304
+ checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
305
+ }
285
306
  }
286
307
  } else {
287
308
  // No version in at least one file — compare content, but not byte-for-byte:
package/lib/doctor.js CHANGED
@@ -3394,9 +3394,26 @@ async function doctorDelphi(options = {}) {
3394
3394
 
3395
3395
  // 5. Config ping: parseable, three distinct callable models. A duplicate
3396
3396
  // model here is a review that will BLOCK later — say so now.
3397
- const cfgPath = configPath || path.join(process.cwd(), '.delphi-config.json');
3397
+ //
3398
+ // #684: resolve the SAME way the runner does. A project-local config wins; a
3399
+ // machine-level one under XDG_CONFIG_HOME (or ~/.config) is the documented
3400
+ // fallback, and a review keeps working on it. Checking only `cwd` reported
3401
+ // FAIL on the layout SKILL.md recommends and told the user to copy the example
3402
+ // into the project — manufacturing the drifted duplicate #541 warns about.
3403
+ //
3404
+ // An explicit `configPath` is an ANSWER, not a hint: a caller that names a
3405
+ // file means that file, so it disables the fallback. Otherwise a test (or a
3406
+ // caller) pinning an absent path silently reads the developer's real global
3407
+ // config and reports PASS — the same vacuous-pass shape this batch is fixing.
3408
+ const projectCfgPath = configPath
3409
+ || path.join(options.projectDir || process.cwd(), '.delphi-config.json');
3410
+ const usingGlobalConfig = !configPath
3411
+ && !fs.existsSync(projectCfgPath)
3412
+ && fs.existsSync(globalConfigPath);
3413
+ const cfgPath = usingGlobalConfig ? globalConfigPath : projectCfgPath;
3414
+ const cfgLabel = 'Delphi config (.delphi-config.json)';
3398
3415
  if (!fs.existsSync(cfgPath)) {
3399
- add('Delphi config (.delphi-config.json)', 'FAIL',
3416
+ add(cfgLabel, 'FAIL',
3400
3417
  `${cfgPath} not found (copy .delphi-config.json.example from the skill directory)`);
3401
3418
  } else {
3402
3419
  try {
@@ -3404,7 +3421,7 @@ async function doctorDelphi(options = {}) {
3404
3421
  const profileName = raw.active_profile || 'default';
3405
3422
  const profile = raw.profiles && raw.profiles[profileName];
3406
3423
  if (!profile) {
3407
- add('Delphi config (.delphi-config.json)', 'FAIL', `profile "${profileName}" not found`);
3424
+ add(cfgLabel, 'FAIL', `profile "${profileName}" not found`);
3408
3425
  } else {
3409
3426
  const experts = profile.experts || {};
3410
3427
  const models = roles.map((r) => experts[r] && experts[r].model);
@@ -3422,10 +3439,14 @@ async function doctorDelphi(options = {}) {
3422
3439
  if (missingRoles.length > 0) problems.push(`missing experts: ${missingRoles.join(', ')}`);
3423
3440
  if (duplicates.length > 0) problems.push(`duplicate models: ${[...new Set(duplicates)].join(', ')}`);
3424
3441
  if (badProviders.length > 0) problems.push(`unusable provider binding: ${badProviders.join(', ')}`);
3425
- add('Delphi config (.delphi-config.json)', 'FAIL', problems.join('; '));
3442
+ add(cfgLabel, 'FAIL', problems.join('; '));
3426
3443
  } else {
3427
- add('Delphi config (.delphi-config.json)', 'PASS',
3428
- `profile "${profileName}": ${models.join(' / ')}`);
3444
+ // Always name the file that was actually read. Without it a user with
3445
+ // both scopes present cannot tell which one the review will use — the
3446
+ // exact ambiguity that made #684 hard to see.
3447
+ const scopeNote = usingGlobalConfig ? ` [global: ${cfgPath}]` : ` [project: ${cfgPath}]`;
3448
+ add(cfgLabel, 'PASS',
3449
+ `profile "${profileName}": ${models.join(' / ')}${scopeNote}`);
3429
3450
  // #574: non-local seats are reported, not blocked. The local-only rule
3430
3451
  // is an operator preference (the 2026-10-03 quota burn), so doctor
3431
3452
  // surfaces the cost exposure without failing a deliberately hosted
@@ -3483,7 +3504,7 @@ async function doctorDelphi(options = {}) {
3483
3504
  }
3484
3505
  }
3485
3506
  } catch (err) {
3486
- add('Delphi config (.delphi-config.json)', 'FAIL', `unparseable: ${err.message}`);
3507
+ add(cfgLabel, 'FAIL', `unparseable: ${err.message}`);
3487
3508
  }
3488
3509
  }
3489
3510
 
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-31T16:53:19.066Z",
5
+ "checked_at": "2026-11-02T02:08:42.160Z",
6
6
  "source": "network"
7
7
  }