@boyingliu01/xp-gate 0.35.19 → 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 (36) hide show
  1. package/hooks/pre-commit +58 -12
  2. package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
  3. package/lib/__tests__/sprint-status-rework.test.js +32 -7
  4. package/lib/__tests__/test-alignment-java-702.test.ts +182 -0
  5. package/lib/__tests__/test-alignment.test.ts +6 -2
  6. package/lib/mattpocock-license-verdict.json +1 -1
  7. package/lib/sprint-status.js +91 -26
  8. package/lib/test-alignment.ts +152 -6
  9. package/mock-policy/AGENTS.md +3 -3
  10. package/mutation/AGENTS.md +3 -3
  11. package/package.json +1 -1
  12. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  13. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  14. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  15. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  16. package/plugins/dsh/lib/index.d.ts +24 -0
  17. package/plugins/dsh/lib/index.d.ts.map +1 -1
  18. package/plugins/dsh/lib/index.js +37 -1
  19. package/plugins/dsh/package.json +1 -1
  20. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  21. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  22. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  23. package/plugins/dsh/src/delphi-version-footer.test.ts +42 -0
  24. package/plugins/dsh/src/index.ts +38 -1
  25. package/plugins/opencode/package.json +1 -1
  26. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/principles/AGENTS.md +3 -3
  34. package/skills/delphi-review/AGENTS.md +3 -3
  35. package/skills/sprint-flow/AGENTS.md +3 -3
  36. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/hooks/pre-commit CHANGED
@@ -4017,6 +4017,30 @@ else
4017
4017
 
4018
4018
  # Check if architecture config exists (use $PROJECT_ROOT — hook may have cd'd to a subdir via ARCH-03)
4019
4019
  if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ] || [ -f "$PROJECT_ROOT/.archlint.yaml" ]; then
4020
+ # #686: an `architecture.yaml` is NOT evidence that layer boundaries are
4021
+ # checked. Gate 6 only ever tested for the file's existence; archlint is
4022
+ # invoked without --config, and even with a config it cannot enforce layers:
4023
+ # @archlinter/cli 0.16.0 lists a `layer_violation` detector but produces no
4024
+ # results for it under any configuration (-A, -d layer_violation, enabled:
4025
+ # true, globs, exact paths — all exit 0 with zero layer findings). See
4026
+ # docs/plans/2026-11-02-arch-01-gate6-layers-not-enforced-design.md §2.2.
4027
+ #
4028
+ # So a repo that declares layers gets a generic-smell check and a green
4029
+ # verdict, which reads as "layers are clean". It is not: nothing looked.
4030
+ # This block records that fact as a per-language reason so the quality
4031
+ # report cannot score the dimension as covered, and emits a marker the
4032
+ # terminal output can be asserted against.
4033
+ #
4034
+ # Deliberately NOT a capability probe: archlint silently tolerates unknown
4035
+ # config keys, so "it did not complain" is a tautological guard — it would
4036
+ # return "supported" no matter what. File presence is the only honest input.
4037
+ GATE_6_LAYERS_UNCHECKED=0
4038
+ if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ]; then
4039
+ GATE_6_LAYERS_UNCHECKED=1
4040
+ echo " ⚠️ [WARN:LAYERS_UNCHECKED] 分层规则未校验:architecture.yaml 声明的层边界当前不被任何检测器消费"
4041
+ echo " (实测 @archlinter/cli 0.16.0 的 layer_violation 启用后亦无输出;见 #686)"
4042
+ echo " 本次仅校验通用架构坏味道(循环依赖 / 上帝类 / 死代码)。"
4043
+ fi
4020
4044
  # #537: per-language reason bookkeeping. Gate 3/4 are single-language gates
4021
4045
  # and get away with one scalar reason; Gate 6 loops, so a scalar would lose
4022
4046
  # information ("TS passed, PS has no detector" cannot be one string).
@@ -4265,6 +4289,20 @@ JAVAARCHROOTS
4265
4289
  GATE_6_LANG_REASONS=""
4266
4290
  _g6_seen=""
4267
4291
  _g6_mixed=0
4292
+ # #686: a language that archlint actually measured and passed still carries a
4293
+ # reason when the repo declared layer boundaries, because the layer dimension
4294
+ # itself was never measured. Only languages WITHOUT a reason get this stamp —
4295
+ # an existing reason (tool missing, tests failed, ...) is more specific and
4296
+ # must not be overwritten. The `-z` test is what keeps this honest: stamping
4297
+ # unconditionally would erase the real reason and make every language look
4298
+ # alike in the report.
4299
+ if [ "${GATE_6_LAYERS_UNCHECKED:-0}" = "1" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4300
+ for _g6_lang in $PROJECT_LANGS; do
4301
+ if [ -z "${GATE_6_REASON_BY_LANG[$_g6_lang]:-}" ]; then
4302
+ GATE_6_REASON_BY_LANG["$_g6_lang"]="layers_config_not_enforced"
4303
+ fi
4304
+ done
4305
+ fi
4268
4306
  for _g6_lang in $PROJECT_LANGS; do
4269
4307
  _g6_r=""
4270
4308
  if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
@@ -4313,19 +4351,27 @@ JAVAARCHROOTS
4313
4351
  echo " Required configuration file (.archlint.yaml / architecture.yaml) is NOT found."
4314
4352
  echo " Architecture Quality Gate requires explicit architecture constraints."
4315
4353
  echo ""
4316
- echo " Quick fix: create .archlint.yaml with: npx archlint init --no-interactive"
4317
- echo " Or architecture.yaml for custom Clean Architecture layer definitions:"
4318
- echo " layers:"
4319
- echo " - name: api"
4320
- echo " paths: [\"src/api/**\"]"
4321
- echo " - name: domain"
4322
- echo " paths: [\"src/domain/**\"]"
4323
- echo " allowed_imports: [domain]"
4324
- echo " - name: infrastructure"
4325
- echo " paths: [\"src/infrastructure/**\"]"
4326
- echo " allowed_imports: [domain, infrastructure]"
4354
+ echo " Quick fix: create .archlint.yaml with: npx archlint init --no-interactive"
4355
+ echo " Or write one by hand — this is archlint's own config format, and the"
4356
+ echo " only one it actually reads:"
4357
+ echo " rules:"
4358
+ echo " cyclic_dependency:"
4359
+ echo " severity: high"
4360
+ echo " god_module:"
4361
+ echo " severity: high"
4362
+ echo ""
4363
+ # #686: this used to recommend a top-level `layers:` block. That format is
4364
+ # silently ignored by @archlinter/cli 0.16.0 (verified against a fixture
4365
+ # with a textbook domain->infra violation: the layer_violation detector is
4366
+ # listed by `archlint detectors list` but produces no results under any
4367
+ # configuration, including -A and an explicit -d layer_violation). The hint
4368
+ # therefore taught users to write a file that bought them nothing while
4369
+ # Gate 6 reported green — the worst possible combination.
4370
+ echo " NOTE: layer-boundary rules are NOT enforced by archlint 0.16.0."
4371
+ echo " Do not write a top-level 'layers:' block expecting it to be checked;"
4372
+ echo " Gate 6 will tell you when that changes. See #686 / #690 / #692."
4327
4373
  echo ""
4328
- echo " After creating architecture.yaml, retry the commit."
4374
+ echo " After creating the config, retry the commit."
4329
4375
  echo " (To skip with warning, create .architecture-skip file)"
4330
4376
  exit 1
4331
4377
  fi
@@ -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);
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-11-01T15:38:49.220Z",
5
+ "checked_at": "2026-11-02T02:08:42.160Z",
6
6
  "source": "network"
7
7
  }