@boyingliu01/xp-gate 0.35.19 → 0.35.21

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 (37) hide show
  1. package/hooks/pre-commit +87 -17
  2. package/hooks/pre-push +44 -5
  3. package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
  4. package/lib/__tests__/sprint-status-rework.test.js +32 -7
  5. package/lib/__tests__/test-alignment-java-702.test.ts +177 -0
  6. package/lib/__tests__/test-alignment.test.ts +6 -2
  7. package/lib/mattpocock-license-verdict.json +1 -1
  8. package/lib/sprint-status.js +91 -26
  9. package/lib/test-alignment.ts +152 -6
  10. package/mock-policy/AGENTS.md +3 -3
  11. package/mutation/AGENTS.md +3 -3
  12. package/package.json +1 -1
  13. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  15. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  16. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  17. package/plugins/dsh/lib/index.d.ts +24 -0
  18. package/plugins/dsh/lib/index.d.ts.map +1 -1
  19. package/plugins/dsh/lib/index.js +37 -1
  20. package/plugins/dsh/package.json +1 -1
  21. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  22. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  23. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  24. package/plugins/dsh/src/delphi-version-footer.test.ts +42 -0
  25. package/plugins/dsh/src/index.ts +38 -1
  26. package/plugins/opencode/package.json +1 -1
  27. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/plugins/qoder/plugin.json +1 -1
  31. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/principles/AGENTS.md +3 -3
  35. package/skills/delphi-review/AGENTS.md +3 -3
  36. package/skills/sprint-flow/AGENTS.md +3 -3
  37. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/hooks/pre-commit CHANGED
@@ -857,9 +857,14 @@ echo ""
857
857
  GATE_0_START=$(gate_start_ms)
858
858
  GATE_0_STATUS="PASS"
859
859
 
860
- CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
860
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null) || CURRENT_BRANCH=""
861
+ CURRENT_BRANCH=$(printf '%s' "$CURRENT_BRANCH" | tr -d '\r\n'); [ -n "$CURRENT_BRANCH" ] || CURRENT_BRANCH="HEAD"
861
862
  PROTECTED_BRANCHES="master develop trunk mainline"
862
- ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
863
+ # #711: same shape as the branch/commit captures above — `git rev-parse` can
864
+ # print a partial line before failing, so `|| echo "."` can yield "<something>\n."
865
+ # and every later `cd "$ROOT_DIR"` / path join would silently use a two-line value.
866
+ ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null) || ROOT_DIR=""
867
+ ROOT_DIR=$(printf '%s' "$ROOT_DIR" | tr -d '\r\n'); [ -n "$ROOT_DIR" ] || ROOT_DIR="."
863
868
 
864
869
  # Optional: project-level .protected-branches config file
865
870
  if [ -f "$ROOT_DIR/.protected-branches" ]; then
@@ -2664,7 +2669,8 @@ else
2664
2669
 
2665
2670
  SKIP_5A_BLOCK=false
2666
2671
  if [ "${SKIP_GATE_5A_BLOCK:-0}" = "1" ]; then
2667
- CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
2672
+ CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null) || CURRENT_BRANCH=""
2673
+ CURRENT_BRANCH=$(printf '%s' "$CURRENT_BRANCH" | tr -d '\r\n'); [ -n "$CURRENT_BRANCH" ] || CURRENT_BRANCH="HEAD"
2668
2674
  if [ "$CURRENT_BRANCH" != "main" ] && [ "$CURRENT_BRANCH" != "master" ]; then
2669
2675
  REASON="${SKIP_GATE_5A_BLOCK_REASON:-not specified}"
2670
2676
  mkdir -p .xp-gate/reports
@@ -4017,6 +4023,30 @@ else
4017
4023
 
4018
4024
  # Check if architecture config exists (use $PROJECT_ROOT — hook may have cd'd to a subdir via ARCH-03)
4019
4025
  if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ] || [ -f "$PROJECT_ROOT/.archlint.yaml" ]; then
4026
+ # #686: an `architecture.yaml` is NOT evidence that layer boundaries are
4027
+ # checked. Gate 6 only ever tested for the file's existence; archlint is
4028
+ # invoked without --config, and even with a config it cannot enforce layers:
4029
+ # @archlinter/cli 0.16.0 lists a `layer_violation` detector but produces no
4030
+ # results for it under any configuration (-A, -d layer_violation, enabled:
4031
+ # true, globs, exact paths — all exit 0 with zero layer findings). See
4032
+ # docs/plans/2026-11-02-arch-01-gate6-layers-not-enforced-design.md §2.2.
4033
+ #
4034
+ # So a repo that declares layers gets a generic-smell check and a green
4035
+ # verdict, which reads as "layers are clean". It is not: nothing looked.
4036
+ # This block records that fact as a per-language reason so the quality
4037
+ # report cannot score the dimension as covered, and emits a marker the
4038
+ # terminal output can be asserted against.
4039
+ #
4040
+ # Deliberately NOT a capability probe: archlint silently tolerates unknown
4041
+ # config keys, so "it did not complain" is a tautological guard — it would
4042
+ # return "supported" no matter what. File presence is the only honest input.
4043
+ GATE_6_LAYERS_UNCHECKED=0
4044
+ if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ]; then
4045
+ GATE_6_LAYERS_UNCHECKED=1
4046
+ echo " ⚠️ [WARN:LAYERS_UNCHECKED] 分层规则未校验:architecture.yaml 声明的层边界当前不被任何检测器消费"
4047
+ echo " (实测 @archlinter/cli 0.16.0 的 layer_violation 启用后亦无输出;见 #686)"
4048
+ echo " 本次仅校验通用架构坏味道(循环依赖 / 上帝类 / 死代码)。"
4049
+ fi
4020
4050
  # #537: per-language reason bookkeeping. Gate 3/4 are single-language gates
4021
4051
  # and get away with one scalar reason; Gate 6 loops, so a scalar would lose
4022
4052
  # information ("TS passed, PS has no detector" cannot be one string).
@@ -4265,6 +4295,20 @@ JAVAARCHROOTS
4265
4295
  GATE_6_LANG_REASONS=""
4266
4296
  _g6_seen=""
4267
4297
  _g6_mixed=0
4298
+ # #686: a language that archlint actually measured and passed still carries a
4299
+ # reason when the repo declared layer boundaries, because the layer dimension
4300
+ # itself was never measured. Only languages WITHOUT a reason get this stamp —
4301
+ # an existing reason (tool missing, tests failed, ...) is more specific and
4302
+ # must not be overwritten. The `-z` test is what keeps this honest: stamping
4303
+ # unconditionally would erase the real reason and make every language look
4304
+ # alike in the report.
4305
+ if [ "${GATE_6_LAYERS_UNCHECKED:-0}" = "1" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4306
+ for _g6_lang in $PROJECT_LANGS; do
4307
+ if [ -z "${GATE_6_REASON_BY_LANG[$_g6_lang]:-}" ]; then
4308
+ GATE_6_REASON_BY_LANG["$_g6_lang"]="layers_config_not_enforced"
4309
+ fi
4310
+ done
4311
+ fi
4268
4312
  for _g6_lang in $PROJECT_LANGS; do
4269
4313
  _g6_r=""
4270
4314
  if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
@@ -4313,19 +4357,27 @@ JAVAARCHROOTS
4313
4357
  echo " Required configuration file (.archlint.yaml / architecture.yaml) is NOT found."
4314
4358
  echo " Architecture Quality Gate requires explicit architecture constraints."
4315
4359
  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]"
4360
+ echo " Quick fix: create .archlint.yaml with: npx archlint init --no-interactive"
4361
+ echo " Or write one by hand — this is archlint's own config format, and the"
4362
+ echo " only one it actually reads:"
4363
+ echo " rules:"
4364
+ echo " cyclic_dependency:"
4365
+ echo " severity: high"
4366
+ echo " god_module:"
4367
+ echo " severity: high"
4368
+ echo ""
4369
+ # #686: this used to recommend a top-level `layers:` block. That format is
4370
+ # silently ignored by @archlinter/cli 0.16.0 (verified against a fixture
4371
+ # with a textbook domain->infra violation: the layer_violation detector is
4372
+ # listed by `archlint detectors list` but produces no results under any
4373
+ # configuration, including -A and an explicit -d layer_violation). The hint
4374
+ # therefore taught users to write a file that bought them nothing while
4375
+ # Gate 6 reported green — the worst possible combination.
4376
+ echo " NOTE: layer-boundary rules are NOT enforced by archlint 0.16.0."
4377
+ echo " Do not write a top-level 'layers:' block expecting it to be checked;"
4378
+ echo " Gate 6 will tell you when that changes. See #686 / #690 / #692."
4327
4379
  echo ""
4328
- echo " After creating architecture.yaml, retry the commit."
4380
+ echo " After creating the config, retry the commit."
4329
4381
  echo " (To skip with warning, create .architecture-skip file)"
4330
4382
  exit 1
4331
4383
  fi
@@ -4942,8 +4994,26 @@ generate_quality_report() {
4942
4994
  local WARN_COUNT=0
4943
4995
  local NONPASS_COUNT=0
4944
4996
 
4945
- COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
4946
- BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
4997
+ # #711: capture the probe output first, then fall back — never `|| echo` a
4998
+ # command whose stdout is only shown on success.
4999
+ #
5000
+ # On a repository with no commits yet, `git rev-parse --abbrev-ref HEAD`
5001
+ # PRINTS "HEAD" and then exits non-zero. With `$(cmd || echo unknown)` the
5002
+ # captured value becomes "HEAD\nunknown" — two lines — and it is embedded
5003
+ # verbatim into the JSON report as `"branch": "HEAD\nunknown"`, which is not
5004
+ # valid JSON (a raw newline cannot appear inside a string literal).
5005
+ #
5006
+ # Gate 12 then reads that report on the NEXT commit and BLOCKS with
5007
+ # "YAML/JSON syntax errors" against a file the gate itself wrote. The first
5008
+ # commit of every freshly-initialised consumer repo therefore fails once for a
5009
+ # reason that has nothing to do with the user's change.
5010
+ COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null) || COMMIT_HASH=""
5011
+ [ -n "$COMMIT_HASH" ] || COMMIT_HASH="unknown"
5012
+ BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || BRANCH=""
5013
+ # A detached HEAD reports the literal word "HEAD"; keep it (it is the truth)
5014
+ # but never allow a multi-line value into the report.
5015
+ BRANCH=$(printf '%s' "$BRANCH" | tr -d '\r\n')
5016
+ [ -n "$BRANCH" ] || BRANCH="unknown"
4947
5017
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
4948
5018
 
4949
5019
  # Scoring per #507 S3: SKIP removes a gate from the denominator (a missing
package/hooks/pre-push CHANGED
@@ -53,7 +53,11 @@ write_pre_push_report() {
53
53
  report_ts=$(date -u +"%Y-%m-%d-%H%M%S")
54
54
  reports_dir=".xp-gate/reports/pre-push"
55
55
  report_file="${reports_dir}/${report_ts}.json"
56
- commit_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
56
+ # #710: `git rev-parse` prints a partial line before it fails, so
57
+ # `$(cmd || echo x)` captures "<partial>\nx". Strip newlines before the value
58
+ # reaches this JSON journal — a raw newline cannot appear inside a JSON string.
59
+ commit_hash=$(git rev-parse HEAD 2>/dev/null) || commit_hash=""
60
+ commit_hash=$(printf '%s' "$commit_hash" | tr -d '\r\n'); [ -n "$commit_hash" ] || commit_hash="unknown"
57
61
  verdict=$([ "$exit_code" -eq 0 ] && echo "PASS" || echo "BLOCKED")
58
62
  mkdir -p "$reports_dir"
59
63
  # #544: the survivor detail is a JSON document, so it cannot be interpolated
@@ -352,7 +356,9 @@ _mp_now_ms() {
352
356
  esac
353
357
  }
354
358
  MP_START_MS="$(_mp_now_ms)"
355
- MP_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
359
+ MP_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || MP_ROOT=""
360
+ MP_ROOT=$(printf '%s' "$MP_ROOT" | tr -d '
361
+ '); [ -n "$MP_ROOT" ] || MP_ROOT="."
356
362
  if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
357
363
  if (cd "$MP_ROOT" && bash scripts/check-hook-mirror.sh); then
358
364
  GATE_MP_STATUS="PASS"
@@ -418,7 +424,9 @@ fi
418
424
  # Uses sprint-gate.sh for standalone validation logic
419
425
  # ============================================================================
420
426
  GATE_S_STATUS="PASS"
421
- PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
427
+ PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || PROJECT_ROOT=""
428
+ PROJECT_ROOT=$(printf '%s' "$PROJECT_ROOT" | tr -d '
429
+ '); [ -n "$PROJECT_ROOT" ] || PROJECT_ROOT="."
422
430
  SPRINT_GATE_SCRIPT=""
423
431
 
424
432
  # Same chain as pre-commit's Gate 11 (#476). The two tiers here both point at
@@ -1128,7 +1136,36 @@ if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
1128
1136
  exit 1
1129
1137
  fi
1130
1138
  echo "⚠️ UI Gate BYPASSED — reason: $XP_GATE_BYPASS_REASON"
1131
- AUDIT_ENTRY="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"branch\":\"$CURRENT_BRANCH\",\"commit\":\"$(git rev-parse HEAD)\",\"user\":\"$(git config user.name || echo 'unknown')\",\"reason\":\"$XP_GATE_BYPASS_REASON\",\"bypass_type\":\"ui-gates\"}"
1139
+ # #710: build the whole record in node rather than interpolating shell
1140
+ # variables into a JSON string. Values come from the environment so no shell
1141
+ # quoting layer can mangle them (an earlier sed-based escaper silently
1142
+ # dropped the reason entirely when its backslash expression broke).
1143
+ #
1144
+ # The reason is free-form USER INPUT: one double quote in it used to end the
1145
+ # JSON string early and turn the audit line into garbage. An audit trail that
1146
+ # can be corrupted by the thing it is auditing is not an audit trail.
1147
+ AUDIT_ENTRY=$(
1148
+ _AJ_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
1149
+ _AJ_BRANCH="$CURRENT_BRANCH" \
1150
+ _AJ_COMMIT="$(git rev-parse HEAD 2>/dev/null)" \
1151
+ _AJ_USER="$(git config user.name 2>/dev/null)" \
1152
+ _AJ_REASON="$XP_GATE_BYPASS_REASON" \
1153
+ node -e '
1154
+ const env = process.env;
1155
+ const clean = (v, fallback) => {
1156
+ const s = String(v === undefined ? "" : v).replace(/[\r\n]+/g, " ").trim();
1157
+ return s === "" ? fallback : s;
1158
+ };
1159
+ process.stdout.write(JSON.stringify({
1160
+ timestamp: env._AJ_TS,
1161
+ branch: clean(env._AJ_BRANCH, "unknown"),
1162
+ commit: clean(env._AJ_COMMIT, "unknown"),
1163
+ user: clean(env._AJ_USER, "unknown"),
1164
+ reason: clean(env._AJ_REASON, ""),
1165
+ bypass_type: "ui-gates",
1166
+ }));
1167
+ ' 2>/dev/null
1168
+ )
1132
1169
  echo "$AUDIT_ENTRY" >> .audit-log.jsonl
1133
1170
  else
1134
1171
  # Detect UI changes in pushed files.
@@ -1310,7 +1347,9 @@ CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1310
1347
  # evidence (#574). It is passed explicitly instead of letting the validator
1311
1348
  # infer it from cwd: the gate's subject is the repo being pushed, and a cwd-
1312
1349
  # derived lookup would bind xp-gate's own config whenever the suite runs here.
1313
- MW_PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
1350
+ MW_PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || MW_PROJECT_ROOT=""
1351
+ MW_PROJECT_ROOT=$(printf '%s' "$MW_PROJECT_ROOT" | tr -d '
1352
+ ')
1314
1353
  if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "$CURRENT_BRANCH" "$CURRENT_TIME" "$MW_PROJECT_ROOT" 2>&1); then
1315
1354
  echo ""
1316
1355
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -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,177 @@
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
+ // The AC id is assembled at runtime so this file contains no literal `AC-…`
32
+ // reference. scripts/__tests__/ac-reference-guard.test.cjs treats every
33
+ // `AC-<WORD>-<digits>` token in tracked sources as a claim about the real
34
+ // specification, and this fixture deliberately names an id that is not in
35
+ // specification.yaml — it only has to satisfy the parser's id shape.
36
+ const FIXTURE_AC = ['AC', 'FIXTURE', '01', '01'].join('-');
37
+
38
+ const JAVA_TEST = `import org.junit.jupiter.api.Test;
39
+ import static org.junit.jupiter.api.Assertions.assertEquals;
40
+ import static org.junit.jupiter.api.Assertions.assertTrue;
41
+
42
+ /**
43
+ * @test REQ-001-01
44
+ * @intent probe intent
45
+ * @covers ${FIXTURE_AC}
46
+ */
47
+ class ProbeTest {
48
+ @Test
49
+ void shouldProbe() {
50
+ assertEquals(1, 1);
51
+ assertEquals(2, 2);
52
+ assertTrue(true);
53
+ }
54
+ }
55
+ `;
56
+
57
+ describe('test-alignment 语言覆盖 (#702)', () => {
58
+ let dir: string;
59
+
60
+ beforeEach(() => {
61
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'align-702-'));
62
+ });
63
+
64
+ afterEach(() => {
65
+ fs.rmSync(dir, { recursive: true, force: true });
66
+ });
67
+
68
+ it('AC-702-01: a .java test file produces a TestCase with its REQ and covers', () => {
69
+ const javaFile = path.join(dir, 'ProbeTest.java');
70
+ fs.writeFileSync(javaFile, JAVA_TEST);
71
+
72
+ const map = parseTestFiles([javaFile]);
73
+
74
+ // The file declares @test REQ-001-01 and a @covers tag. Dropping it
75
+ // means the AC is reported "not covered by any test" — a false gap in a
76
+ // mixed project and a structurally empty report in a pure-Java one.
77
+ expect(map.totalTests).toBe(1);
78
+ expect(map.tests[0].requirementId).toBe('REQ-001-01');
79
+ expect(map.tests[0].name).toBe('shouldProbe');
80
+ expect(map.tests[0].covers).toContain(FIXTURE_AC);
81
+ expect(map.tests[0].intent).toBe('probe intent');
82
+ });
83
+
84
+ it('AC-702-02: JUnit 5 assertion calls are counted', () => {
85
+ const javaFile = path.join(dir, 'ProbeTest.java');
86
+ fs.writeFileSync(javaFile, JAVA_TEST);
87
+
88
+ const map = parseTestFiles([javaFile]);
89
+
90
+ // assertEquals x2 + assertTrue x1. countAssertions() only knew JS/Python
91
+ // patterns, so a Java test would have parsed as zero-assertion.
92
+ expect(map.tests[0].assertions).toBe(3);
93
+ expect(map.totalAssertions).toBe(3);
94
+ });
95
+
96
+ it('AC-702-03: an unsupported test extension is reported, never silently dropped', () => {
97
+ const ktFile = path.join(dir, 'ProbeTest.kt');
98
+ fs.writeFileSync(ktFile, `/** @test REQ-001-01 */\nclass ProbeTest {}\n`);
99
+
100
+ const map = parseTestFiles([ktFile]);
101
+
102
+ // Silently returning an empty map is the bug: the caller cannot tell
103
+ // "no tests here" from "tests here I could not read".
104
+ expect(map.unsupportedFiles).toBeDefined();
105
+ expect(map.unsupportedFiles.length).toBe(1);
106
+ expect(map.unsupportedFiles[0]).toContain('ProbeTest.kt');
107
+ });
108
+
109
+ it('AC-702-04: the supported extension set is explicit and includes .java', () => {
110
+ // A hardcoded if/else chain is why .java fell through: there was no list to
111
+ // check the code against, so a missing branch looked like a complete one.
112
+ expect(Array.isArray(UNSUPPORTED_TEST_EXTENSIONS)).toBe(true);
113
+ expect(UNSUPPORTED_TEST_EXTENSIONS).toContain('.kt');
114
+ expect(UNSUPPORTED_TEST_EXTENSIONS).toContain('.rs');
115
+ expect(UNSUPPORTED_TEST_EXTENSIONS).not.toContain('.java');
116
+ expect(UNSUPPORTED_TEST_EXTENSIONS).not.toContain('.ts');
117
+ expect(UNSUPPORTED_TEST_EXTENSIONS).not.toContain('.go');
118
+ });
119
+ });
120
+
121
+ describe('runFullAlignment surfaces unparsed test files (#702)', () => {
122
+ const SPEC = `specification:
123
+ requirements:
124
+ - id: REQ-001-01
125
+ title: Probe requirement
126
+ acceptance_criteria:
127
+ - id: ${FIXTURE_AC}
128
+ criteria: The probe test asserts something
129
+ test_type: unit
130
+ `;
131
+
132
+ let dir: string;
133
+
134
+ beforeEach(() => {
135
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'align-full-702-'));
136
+ fs.mkdirSync(path.join(dir, 'spec'), { recursive: true });
137
+ fs.mkdirSync(path.join(dir, 'tests'), { recursive: true });
138
+ fs.writeFileSync(path.join(dir, 'spec', 'specification.yaml'), SPEC);
139
+ });
140
+
141
+ afterEach(() => {
142
+ fs.rmSync(dir, { recursive: true, force: true });
143
+ });
144
+
145
+ it('AC-702-05: a pure-Kotlin test dir cannot PASS on an empty parsed set', () => {
146
+ fs.writeFileSync(
147
+ path.join(dir, 'tests', 'ProbeTest.kt'),
148
+ '/** @test REQ-001-01 */\nclass ProbeTest {}\n',
149
+ );
150
+
151
+ const report = runFullAlignment({
152
+ specPath: path.join(dir, 'spec', 'specification.yaml'),
153
+ testDirs: [path.join(dir, 'tests')],
154
+ });
155
+
156
+ // Zero parsed tests + zero parseable files used to look like a clean run.
157
+ // It is the opposite: nothing was read, so nothing can be certified.
158
+ expect(report.alignment_status).toBe('FAIL');
159
+ expect(report.errors.some(e => e.includes('ProbeTest.kt'))).toBe(true);
160
+ expect(report.anti_pattern_detected).toBe(true);
161
+ });
162
+
163
+ it('AC-702-06: a high score does not override the presence of unparsed files', () => {
164
+ // Java test covers the AC, so the score would be high on its own.
165
+ fs.writeFileSync(path.join(dir, 'tests', 'ProbeTest.java'), JAVA_TEST);
166
+ // One unparseable Rust test is enough to make the number untrustworthy.
167
+ fs.writeFileSync(path.join(dir, 'tests', 'ProbeTest.rs'), 'fn probe() {}\n');
168
+
169
+ const report = runFullAlignment({
170
+ specPath: path.join(dir, 'spec', 'specification.yaml'),
171
+ testDirs: [path.join(dir, 'tests')],
172
+ });
173
+
174
+ expect(report.errors.some(e => e.includes('ProbeTest.rs'))).toBe(true);
175
+ expect(report.alignment_status).toBe('FAIL');
176
+ });
177
+ });