@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.
- package/hooks/pre-commit +58 -12
- package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
- package/lib/__tests__/sprint-status-rework.test.js +32 -7
- package/lib/__tests__/test-alignment-java-702.test.ts +182 -0
- package/lib/__tests__/test-alignment.test.ts +6 -2
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/sprint-status.js +91 -26
- package/lib/test-alignment.ts +152 -6
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/index.d.ts +24 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +37 -1
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-version-footer.test.ts +42 -0
- package/plugins/dsh/src/index.ts +38 -1
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- 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
|
-
|
|
4317
|
-
echo " Or
|
|
4318
|
-
echo "
|
|
4319
|
-
echo "
|
|
4320
|
-
echo "
|
|
4321
|
-
echo "
|
|
4322
|
-
echo "
|
|
4323
|
-
echo "
|
|
4324
|
-
echo "
|
|
4325
|
-
|
|
4326
|
-
|
|
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
|
|
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:
|
|
134
|
+
started_at: hoursAgo(1),
|
|
115
135
|
isolation: { worktree_path: null, branch: 'main' },
|
|
116
136
|
phase_history: [],
|
|
117
|
-
metrics: { completed_at:
|
|
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(
|
|
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:
|
|
179
|
+
started_at: hoursAgo(1),
|
|
159
180
|
isolation: { worktree_path: null, branch: 'main' },
|
|
160
181
|
phase_history: [],
|
|
161
|
-
metrics: { completed_at:
|
|
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:
|
|
246
|
+
started_at: hoursAgo(1),
|
|
222
247
|
isolation: { worktree_path: null, branch: 'main' },
|
|
223
248
|
phase_history: [],
|
|
224
|
-
metrics: { completed_at:
|
|
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);
|