@boyingliu01/xp-gate 0.10.17 → 0.11.0

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-push CHANGED
@@ -15,6 +15,60 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━
15
15
  CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
16
16
  REMOTE="$1"
17
17
  URL="$2"
18
+ _PRE_PUSH_REPORT_DONE=0
19
+
20
+ write_pre_push_report() {
21
+ local exit_code="$1"
22
+ local timestamp report_ts reports_dir report_file commit_hash verdict
23
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
24
+ report_ts=$(date -u +"%Y-%m-%d-%H%M%S")
25
+ reports_dir=".xp-gate/reports/pre-push"
26
+ report_file="${reports_dir}/${report_ts}.json"
27
+ commit_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
28
+ verdict=$([ "$exit_code" -eq 0 ] && echo "PASS" || echo "BLOCKED")
29
+ mkdir -p "$reports_dir"
30
+ python3 - <<PY 2>/dev/null || true
31
+ import json
32
+ from pathlib import Path
33
+ pushed_files = '''${PUSHED_FILES:-}'''.splitlines()
34
+ report = {
35
+ 'timestamp': '$timestamp',
36
+ 'trigger': 'pre-push',
37
+ 'branch': '$CURRENT_BRANCH',
38
+ 'commit': '$commit_hash',
39
+ 'changed_files': [f for f in pushed_files if f],
40
+ 'overall': {
41
+ 'score': '10.0/10' if '$verdict' == 'PASS' else '0.0/10',
42
+ 'gates_passed': 1 if '$verdict' == 'PASS' else 0,
43
+ 'gates_total': 1,
44
+ 'verdict': '$verdict',
45
+ },
46
+ 'gates': [
47
+ {'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
48
+ {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {}},
49
+ {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PYTHON_STATUS:-SKIP}', 'details': {}},
50
+ {'id': 'M2', 'name': 'Mock Density', 'status': '${GATE_M2_STATUS:-SKIP}', 'details': {}},
51
+ {'id': 'M3', 'name': 'Mock Layering', 'status': '${GATE_M3_STATUS:-SKIP}', 'details': {}},
52
+ {'id': 'UI', 'name': 'UI Sprint Gates', 'status': '${GATE_UI_STATUS:-SKIP}', 'details': {}},
53
+ {'id': 'Delphi', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL'}}
54
+ ],
55
+ 'warnings': [],
56
+ 'errors': [] if '$verdict' == 'PASS' else ['pre-push hook exited with code $exit_code'],
57
+ }
58
+ Path('$report_file').write_text(json.dumps(report, indent=2) + '\n')
59
+ PY
60
+ }
61
+
62
+ _pre_push_report_on_exit() {
63
+ local saved_exit=$?
64
+ if [ "$_PRE_PUSH_REPORT_DONE" = "1" ]; then
65
+ exit "$saved_exit"
66
+ fi
67
+ _PRE_PUSH_REPORT_DONE=1
68
+ write_pre_push_report "$saved_exit"
69
+ exit "$saved_exit"
70
+ }
71
+ trap '_pre_push_report_on_exit' EXIT
18
72
 
19
73
  # Get files being pushed in this push operation
20
74
  # pre-push receives stdin with ref information
@@ -26,9 +80,26 @@ while read local_ref local_sha remote_ref remote_sha; do
26
80
  echo " ℹ️ Branch deletion detected ($local_ref). Skipping validation."
27
81
  continue
28
82
  elif [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
29
- # New branch - compare against empty tree
30
- PUSHED_FILES=$(git diff-tree -r --name-only HEAD)
31
- TS_FILES=$(git diff-tree -r --name-only HEAD | grep '\.ts$' || true)
83
+ # New branch - compare cumulative diff vs base branch
84
+ # Use git merge-base HEAD main/master to find the branch point,
85
+ # then diff the full branch range. Falls back to HEAD-only diff if no base found.
86
+ BASE_BRANCH="origin/main"
87
+ if ! git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1; then
88
+ BASE_BRANCH="origin/master"
89
+ fi
90
+ if git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1; then
91
+ MERGE_BASE=$(git merge-base HEAD "$BASE_BRANCH" 2>/dev/null)
92
+ if [ -n "$MERGE_BASE" ] && [ "$MERGE_BASE" != "$(git rev-parse HEAD)" ]; then
93
+ PUSHED_FILES=$(git diff --name-only "$MERGE_BASE"...HEAD 2>/dev/null)
94
+ TS_FILES=$(git diff --name-only "$MERGE_BASE"...HEAD 2>/dev/null | grep '\.ts$' || true)
95
+ else
96
+ PUSHED_FILES=$(git diff-tree -r --name-only HEAD)
97
+ TS_FILES=$(git diff-tree -r --name-only HEAD | grep '\.ts$' || true)
98
+ fi
99
+ else
100
+ PUSHED_FILES=$(git diff-tree -r --name-only HEAD)
101
+ TS_FILES=$(git diff-tree -r --name-only HEAD | grep '\.ts$' || true)
102
+ fi
32
103
  else
33
104
  # Existing branch - compare against what we're pushing from
34
105
  PUSHED_FILES=$(git diff-tree -r --name-only "$remote_sha" "$local_sha")
@@ -72,6 +143,94 @@ echo "Files changed: $FILES_CHANGED"
72
143
  echo "Lines: +$LINES_ADDED -$LINES_DELETED"
73
144
  echo ""
74
145
 
146
+ # ============================================================================
147
+ # GATE S: SPRINT FLOW ENFORCEMENT
148
+ # Validates sprint state consistency before push
149
+ # Uses sprint-gate.sh for standalone validation logic
150
+ # ============================================================================
151
+ GATE_S_STATUS="PASS"
152
+ PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"
153
+ SPRINT_GATE_SCRIPT=""
154
+
155
+ if [ -f "$PROJECT_ROOT/githooks/sprint-gate.sh" ]; then
156
+ SPRINT_GATE_SCRIPT="$PROJECT_ROOT/githooks/sprint-gate.sh"
157
+ elif [ -f "$(dirname "$0")/sprint-gate.sh" ]; then
158
+ SPRINT_GATE_SCRIPT="$(dirname "$0")/sprint-gate.sh"
159
+ fi
160
+
161
+ if [ -n "$SPRINT_GATE_SCRIPT" ]; then
162
+ if ! bash "$SPRINT_GATE_SCRIPT" --pre-push; then
163
+ GATE_S_STATUS="BLOCK"
164
+ echo "❌ BLOCKED - Gate S: Sprint Flow Enforcement"
165
+ exit 1
166
+ fi
167
+ else
168
+ echo "⏭️ SKIPPED - Gate S: Sprint Flow (sprint-gate.sh not found)"
169
+ GATE_S_STATUS="SKIP"
170
+ fi
171
+ echo ""
172
+
173
+ # ============================================================================
174
+ # GATE 10: BUILD INTEGRITY CHECK (TypeScript)
175
+ # ============================================================================
176
+ GATE_10_STATUS="SKIP"
177
+
178
+ if [[ -f "package.json" ]] && [[ -f "tsconfig.json" ]] && [[ -n "$TS_FILES" ]]; then
179
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
180
+ echo " GATE 10: BUILD INTEGRITY CHECK"
181
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
182
+
183
+ GATE_10_SCRIPT=""
184
+ if [[ -f "$PROJECT_ROOT/src/build-integrity/gate-10.ts" ]]; then
185
+ GATE_10_SCRIPT="$PROJECT_ROOT/src/build-integrity/gate-10.ts"
186
+ elif [[ -f ".xp-gate/modules/build-integrity/gate-10.ts" ]]; then
187
+ GATE_10_SCRIPT=".xp-gate/modules/build-integrity/gate-10.ts"
188
+ elif [[ -f "$HOME/.config/xp-gate/modules/build-integrity/gate-10.ts" ]]; then
189
+ GATE_10_SCRIPT="$HOME/.config/xp-gate/modules/build-integrity/gate-10.ts"
190
+ fi
191
+
192
+ if [[ -n "$GATE_10_SCRIPT" ]]; then
193
+ # Build comma-separated list of absolute file paths for changed TS files
194
+ GATE_10_FILE_LIST=""
195
+ while IFS= read -r f; do
196
+ if [ -z "$GATE_10_FILE_LIST" ]; then
197
+ GATE_10_FILE_LIST="$f"
198
+ else
199
+ GATE_10_FILE_LIST="$GATE_10_FILE_LIST,$f"
200
+ fi
201
+ done <<< "$TS_FILES"
202
+
203
+ GATE_10_OUTPUT=$(mktemp)
204
+ if timeout 120s npx tsx "$GATE_10_SCRIPT" --changed-files "$GATE_10_FILE_LIST" --project-root "$PROJECT_ROOT" > "$GATE_10_OUTPUT" 2>&1; then
205
+ cat "$GATE_10_OUTPUT"
206
+ GATE_10_STATUS="PASS"
207
+ echo "✅ Gate 10: PASS"
208
+ else
209
+ GATE_10_EXIT=$?
210
+ cat "$GATE_10_OUTPUT"
211
+ if [[ $GATE_10_EXIT -eq 124 ]]; then
212
+ echo "⏱️ Gate 10: TIMEOUT (120s). Allowing push with warning."
213
+ GATE_10_STATUS="TIMEOUT"
214
+ else
215
+ echo ""
216
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
217
+ echo " ❌ GATE 10 FAILED — PUSH BLOCKED"
218
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
219
+ echo "Build integrity check failed. Fix type errors, broken imports,"
220
+ echo "or package manifest issues before pushing."
221
+ rm -f "$GATE_10_OUTPUT"
222
+ exit 1
223
+ fi
224
+ fi
225
+ rm -f "$GATE_10_OUTPUT"
226
+ else
227
+ echo "⚠️ Gate 10 script not found. SKIP — Gate 10."
228
+ fi
229
+ else
230
+ echo "📚 Not a TypeScript project or no TS files changed. SKIP — Gate 10."
231
+ fi
232
+ echo ""
233
+
75
234
  # ============================================================================
76
235
  # GATE M: MUTATION TESTING
77
236
  # ============================================================================
@@ -115,13 +274,22 @@ else
115
274
  echo "🧬 Running mutation tests on changed files..."
116
275
  echo "$CHANGED_SOURCE_FILES"
117
276
 
118
- # Check if mutation gate script exists
119
- if [[ ! -f "src/mutation/gate-m.ts" ]]; then
120
- echo "⚠️ Gate M script (src/mutation/gate-m.ts) not found. SKIP — Gate M."
277
+ # Check for mutation gate script in installed modules first, then project src/
278
+ MUTATION_SCRIPT=""
279
+ if [ -f ".xp-gate/modules/mutation/gate-m.ts" ]; then
280
+ MUTATION_SCRIPT=".xp-gate/modules/mutation/gate-m.ts"
281
+ elif [ -f "src/mutation/gate-m.ts" ]; then
282
+ MUTATION_SCRIPT="src/mutation/gate-m.ts"
283
+ elif [ -f "$HOME/.config/xp-gate/modules/mutation/gate-m.ts" ]; then
284
+ MUTATION_SCRIPT="$HOME/.config/xp-gate/modules/mutation/gate-m.ts"
285
+ fi
286
+
287
+ if [[ -z "$MUTATION_SCRIPT" ]]; then
288
+ echo "⚠️ Gate M script not found. SKIP — Gate M."
121
289
  else
122
290
  # Run mutation gate with timeout matching stryker.conf.json (timeoutMS: 600000 = 10min)
123
291
  MUTATION_OUTPUT=$(mktemp)
124
- timeout 600s npx tsx src/mutation/gate-m.ts --changed-files "$CHANGED_SOURCE_FILES" > "$MUTATION_OUTPUT" 2>&1
292
+ timeout 600s npx tsx $MUTATION_SCRIPT --changed-files "$CHANGED_SOURCE_FILES" > "$MUTATION_OUTPUT" 2>&1
125
293
  MUTATION_EXIT=$?
126
294
 
127
295
  case $MUTATION_EXIT in
@@ -162,7 +330,69 @@ else
162
330
  fi
163
331
 
164
332
  # ============================================================================
165
- # GATE M2: MOCK DENSITY CHECK (BLOCK at 50%, ADVISORY at 30%)
333
+ # GATE M (Python): Incremental Mutation Testing via mutmut
334
+ # Runs after TypeScript Gate M; uses the same gate-m.ts orchestrator with
335
+ # MutmutRunner for Python files.
336
+ # ============================================================================
337
+ echo ""
338
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
339
+ echo " GATE M (Python): MUTATION TESTING"
340
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
341
+
342
+ # Collect changed Python source files (exclude tests, venv, cached)
343
+ PY_FILES=$(echo "$PUSHED_FILES" | grep -E '\.py$' | grep -v '__pycache__' | grep -v '\.test\.py' | grep -v '/test_' | grep -v '/tests/' || true)
344
+ PY_SOURCE_FILES=$(echo "$PY_FILES" | grep -v '/venv/' | grep -v '/\.venv/' || true)
345
+
346
+ if [ -z "$PY_SOURCE_FILES" ]; then
347
+ echo "📚 No changed Python source files. SKIP — Gate M (Python)."
348
+ else
349
+ PYTHON_FILE_LIST=$(echo "$PY_SOURCE_FILES" | tr '\n' ',' | sed 's/,$//')
350
+ echo "🐍 Changed Python files: $PYTHON_FILE_LIST"
351
+
352
+ if detect_python_mutation_testable 2>/dev/null; then
353
+ if [ -f "src/mutation/gate-m.ts" ]; then
354
+ MUTATION_OUTPUT=$(mktemp)
355
+ timeout 120s npx tsx src/mutation/gate-m.ts --changed-files "$PYTHON_FILE_LIST" > "$MUTATION_OUTPUT" 2>&1
356
+ MUTATION_EXIT=$?
357
+
358
+ case $MUTATION_EXIT in
359
+ 0)
360
+ echo "✅ Gate M (Python): PASS"
361
+ ;;
362
+ 1)
363
+ echo ""
364
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
365
+ echo " ❌ GATE M (Python) FAILED - PUSH BLOCKED"
366
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
367
+ echo ""
368
+ cat "$MUTATION_OUTPUT"
369
+ echo ""
370
+ rm -f "$MUTATION_OUTPUT"
371
+ exit 1
372
+ ;;
373
+ 124)
374
+ cat "$MUTATION_OUTPUT"
375
+ echo "⏱ Gate M (Python): TIMEOUT (120s). Allowing push with warning."
376
+ ;;
377
+ *)
378
+ cat "$MUTATION_OUTPUT"
379
+ echo "⚠ Gate M (Python): Unexpected exit code $MUTATION_EXIT. Allowing push with warning."
380
+ ;;
381
+ esac
382
+
383
+ rm -f "$MUTATION_OUTPUT"
384
+ else
385
+ echo "⚠ Gate M script not found. SKIP — Gate M (Python)."
386
+ fi
387
+ else
388
+ echo "⚠ mutmut not installed. SKIP — Gate M (Python)."
389
+ fi
390
+ fi
391
+
392
+ # ============================================================================
393
+ # GATE M2: MOCK DENSITY CHECK (BLOCK at 30%, configurable via .mockpolicyrc)
394
+ # Phase 1: WARNING mode (collect false positive data before enabling BLOCK)
395
+ # Per-layer thresholds: .mockpolicyrc layers.*.maxMockDensity
166
396
  # ============================================================================
167
397
  echo ""
168
398
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -182,10 +412,20 @@ if [ -n "$PY_TEST_FILES" ]; then
182
412
  fi
183
413
 
184
414
  if [ -z "$ALL_PUSHED_TEST_FILES" ]; then
185
- echo " No test files in push. SKIP — Mock density check."
415
+ echo "⏭️ SKIPPED - Mock density check (no test files in push)"
186
416
  else
187
417
  MOCK_BLOCKED=false
188
418
 
419
+ # Read .mockpolicyrc configuration
420
+ MOCK_THRESHOLD=30 # Default threshold
421
+ if [ -f ".mockpolicyrc" ]; then
422
+ # Read global mock-threshold if present
423
+ CUSTOM_THRESHOLD=$(grep -oE '"mock-threshold"\s*:\s*[0-9]+' .mockpolicyrc | grep -oE '[0-9]+$' || true)
424
+ if [ -n "$CUSTOM_THRESHOLD" ]; then
425
+ MOCK_THRESHOLD=$CUSTOM_THRESHOLD
426
+ fi
427
+ fi
428
+
189
429
  for test_file in $ALL_PUSHED_TEST_FILES; do
190
430
  if [ -f "$test_file" ]; then
191
431
  # Count mock keyword references (precise patterns only)
@@ -196,9 +436,8 @@ else
196
436
  for kw in 'jest\.mock' 'vi\.mock' 'jest\.spyOn' 'vi\.spyOn' 'jest\.fn' 'vi\.fn' \
197
437
  'mockResolvedValue' 'mockRejectedValue' 'mockReturnValue' 'mockImplementation' \
198
438
  'createMock' 'mockReset' 'mockClear' 'mockRestore' 'MagicMock' 'unittest\.mock' \
199
- '\.patch(' 'gomock' 'mockgen' '\.EXPECT()'; do
200
- c=$(grep -o "$kw" "$test_file" 2>/dev/null | wc -l || echo "0")
201
- # Guard against empty/non-numeric values that break bash arithmetic
439
+ '\.patch(' 'gomock' 'mockgen' '.EXPECT()'; do
440
+ c=$(grep -o "$kw" "$test_file" 2>/dev/null | wc -l || true)
202
441
  c=${c//[^0-9]/}
203
442
  c=${c:-0}
204
443
  MOCK_COUNT=$((MOCK_COUNT + c))
@@ -216,32 +455,185 @@ else
216
455
  MOCK_DENSITY="0"
217
456
  fi
218
457
 
219
- THRESHOLD_50=$(awk "BEGIN {print ($MOCK_DENSITY > 50) ? 1 : 0}")
220
- THRESHOLD_30=$(awk "BEGIN {print ($MOCK_DENSITY > 30) ? 1 : 0}")
458
+ THRESHOLD_BLOCK=$(awk "BEGIN {print ($MOCK_DENSITY > $MOCK_THRESHOLD) ? 1 : 0}")
221
459
 
222
460
  # Check for @mock-justified annotation with reason text (min 10 chars)
223
461
  HAS_JUSTIFIED=$(grep -qE '@mock-justified\s*:\s*.{10,}' "$test_file" 2>/dev/null && echo "true" || echo "false")
224
462
 
225
- if [ "$THRESHOLD_50" = "1" ]; then
463
+ if [ "$THRESHOLD_BLOCK" = "1" ]; then
226
464
  if [ "$HAS_JUSTIFIED" = "false" ]; then
227
- echo "❌ BLOCKED: $test_file Mock density ${MOCK_DENSITY}% exceeds 50% threshold"
465
+ # Phase 1: WARNING mode (will become BLOCK in Phase 2)
466
+ echo "⚠️ WARNING: $test_file — Mock density ${MOCK_DENSITY}% exceeds ${MOCK_THRESHOLD}% threshold"
228
467
  echo " Must: Reduce mocks OR add '// @mock-justified: <reason>' (min 10 char explanation)"
229
- MOCK_BLOCKED=true
468
+ echo " (Phase 1: WARNING mode — will become BLOCK after baseline analysis)"
469
+ # Note: Do NOT set MOCK_BLOCKED=true in Phase 1
230
470
  else
231
- echo "⚠️ WARNING: $test_file — Mock density ${MOCK_DENSITY}% (justified by annotation)"
471
+ echo " $test_file — Mock density ${MOCK_DENSITY}% (justified by annotation)"
232
472
  fi
233
- elif [ "$THRESHOLD_30" = "1" ]; then
234
- echo "ℹ️ ADVISORY: $test_file — Mock density ${MOCK_DENSITY}% (consider integration tests)"
235
473
  else
236
- echo "✅ $test_file — Mock density ${MOCK_DENSITY}% (acceptable)"
474
+ echo "✅ $test_file — Mock density ${MOCK_DENSITY}% (within ${MOCK_THRESHOLD}% threshold)"
237
475
  fi
238
476
  fi
239
477
  done
240
478
 
241
- if [ "$MOCK_BLOCKED" = true ]; then
242
- echo ""
243
- echo "❌ PUSH BLOCKED Mock density too high without justification"
244
- exit 1
479
+ # Phase 1: WARNING mode only — no blocking
480
+ # Phase 2 (after baseline analysis): enable BLOCK based on false positive rate
481
+ # if [ "$MOCK_BLOCKED" = true ]; then
482
+ # echo ""
483
+ # echo "❌ PUSH BLOCKED — Mock density too high without justification"
484
+ # exit 1
485
+ # fi
486
+ fi
487
+
488
+ # ============================================================================
489
+ # Gate M3: Mock Layering Strategy
490
+ # Validates mock policies based on test layer and dependency scope
491
+ # Runs only in non-main branches, TypeScript projects with the module available
492
+ # ============================================================================
493
+ run_gate_m3() {
494
+ local changed_files="$1"
495
+ local project_root="$2"
496
+
497
+ echo ""
498
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
499
+ echo " GATE M3: MOCK LAYERING STRATEGY"
500
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
501
+
502
+ # Skip on main/master
503
+ if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
504
+ echo " SKIP — pushing to main/master"
505
+ return 0
506
+ fi
507
+
508
+ # Check for mock-policy module in installed modules first, then project src/
509
+ local GATE_M3_SCRIPT=""
510
+ if [ -f ".xp-gate/modules/mock-policy/gate-m3.ts" ]; then
511
+ GATE_M3_SCRIPT=".xp-gate/modules/mock-policy/gate-m3.ts"
512
+ elif [ -f "./src/mock-policy/gate-m3.ts" ]; then
513
+ GATE_M3_SCRIPT="./src/mock-policy/gate-m3.ts"
514
+ fi
515
+
516
+ if [[ -z "$GATE_M3_SCRIPT" ]]; then
517
+ echo " SKIP — module not found"
518
+ return 0
519
+ fi
520
+
521
+ if [[ ! -f "package.json" ]] || [[ ! -f "tsconfig.json" ]]; then
522
+ echo " SKIP — TypeScript only"
523
+ return 0
524
+ fi
525
+
526
+ # Filter to test files only
527
+ local test_files=$(echo "$changed_files" | grep -E '\.(test|spec)\.ts$' || true)
528
+
529
+ if [[ -z "$test_files" ]]; then
530
+ echo " SKIP — no test files changed"
531
+ return 0
532
+ fi
533
+
534
+ echo " Changed test files: $(echo "$test_files" | wc -l)"
535
+
536
+ # Convert to array safely (handles spaces in filenames)
537
+ local args=()
538
+ while IFS= read -r f; do
539
+ [[ -n "$f" ]] && args+=("$f")
540
+ done <<< "$test_files"
541
+
542
+ if npx tsx "$GATE_M3_SCRIPT" "${args[@]}"; then
543
+ echo "✅ Gate M3: PASS"
544
+ return 0
545
+ else
546
+ local exit_code=$?
547
+ if [[ "$exit_code" -eq 1 ]]; then
548
+ echo ""
549
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
550
+ echo " ❌ GATE M3 FAILED - PUSH BLOCKED"
551
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
552
+ echo ""
553
+ echo "Mock layering violations found. Review and fix or adjust"
554
+ echo ".mockpolicyrc severity to 'warning' for advisory mode."
555
+ echo ""
556
+ return 1
557
+ fi
558
+ echo "⚠️ Gate M3: Unexpected error — allowing push with warning"
559
+ return 0
560
+ fi
561
+ }
562
+
563
+ run_gate_m3 "$TS_FILES" "$PROJECT_ROOT"
564
+
565
+ # ============================================================================
566
+ # GATE UI: UI SPRINT DETECTION & RESULT VALIDATION
567
+ # ============================================================================
568
+ echo ""
569
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
570
+ echo " GATE UI: UI SPRINT QUALITY GATES"
571
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
572
+
573
+ # Skip on main/master (pre-reviewed via PR)
574
+ if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
575
+ echo "⚠️ Pushing to main/master — UI Gate skipped (pre-reviewed via PR)"
576
+ else
577
+ # Check bypass
578
+ if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
579
+ if [[ -z "$XP_GATE_BYPASS_REASON" || ${#XP_GATE_BYPASS_REASON// /} -lt 10 ]]; then
580
+ echo "❌ XP_GATE_BYPASS_REASON required (min 10 non-whitespace chars)"
581
+ exit 1
582
+ fi
583
+ echo "⚠️ UI Gate BYPASSED — reason: $XP_GATE_BYPASS_REASON"
584
+ 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\"}"
585
+ echo "$AUDIT_ENTRY" >> .audit-log.jsonl
586
+ else
587
+ # Detect UI changes in pushed files
588
+ UI_DETECTION=$(echo "$PUSHED_FILES" | npx -y tsx src/npm-package/lib/ui-detector.ts --push-mode --from-stdin 2>/dev/null) || true
589
+
590
+ IS_UI_SPRINT=$(echo "$UI_DETECTION" | jq -r '.isUiSprint' 2>/dev/null || echo "false")
591
+
592
+ if [[ "$IS_UI_SPRINT" == "true" ]]; then
593
+ UI_RESULT_FILE=".ui-gate-result.json"
594
+ MISSING_RESULTS=()
595
+
596
+ if [[ ! -f "$UI_RESULT_FILE" ]]; then
597
+ MISSING_RESULTS+=("$UI_RESULT_FILE")
598
+ fi
599
+
600
+ if [[ ${#MISSING_RESULTS[@]} -gt 0 ]]; then
601
+ echo ""
602
+ echo "❌ UI sprint detected — missing result file(s):"
603
+ for f in "${MISSING_RESULTS[@]}"; do echo " - $f"; done
604
+ echo ""
605
+ echo "Before pushing, run: /design-review + /qa-only in your Agent session"
606
+ echo "Or for non-sprint flows: xp-gate ui-review"
607
+ echo ""
608
+ echo "Sprint Flow: Phase 3 automatically triggers these when UI changes detected."
609
+ exit 1
610
+ fi
611
+
612
+ # Validate result file
613
+ RESULT_COMMIT=$(jq -r '.commit' "$UI_RESULT_FILE" 2>/dev/null)
614
+ RESULT_VERDICT=$(jq -r '.verdict' "$UI_RESULT_FILE" 2>/dev/null)
615
+ RESULT_EXPIRES=$(jq -r '.expires' "$UI_RESULT_FILE" 2>/dev/null)
616
+
617
+ if [[ "$RESULT_COMMIT" != "$(git rev-parse HEAD)" ]]; then
618
+ echo "❌ .ui-gate-result.json outdated — expected commit $(git rev-parse HEAD), got $RESULT_COMMIT"
619
+ exit 1
620
+ fi
621
+
622
+ if [[ "$RESULT_VERDICT" != "APPROVED" ]]; then
623
+ echo "❌ .ui-gate-result.json verdict: $RESULT_VERDICT (must be APPROVED)"
624
+ exit 1
625
+ fi
626
+
627
+ CURRENT_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
628
+ if [[ "$CURRENT_TIME" > "$RESULT_EXPIRES" ]]; then
629
+ echo "❌ .ui-gate-result.json expired ($RESULT_EXPIRES)"
630
+ exit 1
631
+ fi
632
+
633
+ echo "✅ Gate UI: PASS — UI changes reviewed and approved"
634
+ else
635
+ echo "ℹ️ No UI changes detected — skipping UI Gate"
636
+ fi
245
637
  fi
246
638
  fi
247
639
 
@@ -292,7 +684,16 @@ if ! command -v jq &> /dev/null; then
292
684
  fi
293
685
 
294
686
  # Validate result file content
687
+ # SHA self-reference paradox fix: if the result file was committed as part of
688
+ # HEAD, its content references HEAD^ (the commit being reviewed), not HEAD.
295
689
  CURRENT_COMMIT=$(git rev-parse HEAD)
690
+ if git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | grep -qx "$RESULT_FILE"; then
691
+ # Result file is part of the latest commit — compare against the reviewed commit
692
+ EXPECTED_COMMIT=$(git rev-parse HEAD^ 2>/dev/null || echo "unknown")
693
+ else
694
+ # Result file is working-tree only — compare against current HEAD
695
+ EXPECTED_COMMIT="$CURRENT_COMMIT"
696
+ fi
296
697
 
297
698
  # Check JSON validity
298
699
  if ! jq empty "$RESULT_FILE" 2>/dev/null; then
@@ -325,15 +726,15 @@ if [ "$RESULT_BRANCH" != "$CURRENT_BRANCH" ]; then
325
726
  fi
326
727
 
327
728
  # Commit match check
328
- if [ "$RESULT_COMMIT" != "$CURRENT_COMMIT" ]; then
729
+ if [ "$RESULT_COMMIT" != "$EXPECTED_COMMIT" ]; then
329
730
  echo ""
330
731
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
331
732
  echo " ❌ RESULT FILE OUTDATED - PUSH BLOCKED"
332
733
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
333
734
  echo ""
334
735
  echo "Result file is for a different commit:"
335
- echo " Expected: $CURRENT_COMMIT"
336
- echo " Found: $RESULT_COMMIT"
736
+ echo " Expected: $EXPECTED_COMMIT"
737
+ echo " Found: $RESULT_COMMIT"
337
738
  echo ""
338
739
  echo "Re-run: /delphi-review --mode code-walkthrough"
339
740
  echo ""
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-06-26
4
- **Commit:** afab469
3
+ **Generated:** 2026-06-29
4
+ **Commit:** 8efd33c
5
5
  **Branch:** main
6
- **Version:** 0.10.17.0
6
+ **Version:** 0.11.0.1
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-06-26
4
- **Commit:** afab469
3
+ **Generated:** 2026-06-29
4
+ **Commit:** 8efd33c
5
5
  **Branch:** main
6
- **Version:** 0.10.17.0
6
+ **Version:** 0.11.0.1
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.10.17",
3
+ "version": "0.11.0",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.10.17",
3
+ "version": "0.11.0",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 10 quality gates (Gate 0-9), Sprint Flow (11 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-06-26
4
- **Commit:** afab469
3
+ **Generated:** 2026-06-29
4
+ **Commit:** 8efd33c
5
5
  **Branch:** main
6
- **Version:** 0.10.17.0
6
+ **Version:** 0.11.0.1
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, 3 experts from ≥2 providers, domestic models only). Supports design + code-walkthrough modes.