@boyingliu01/xp-gate 0.30.2 → 0.30.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/adapters/iac.sh +45 -4
  2. package/adapters/java.sh +82 -16
  3. package/adapters/powershell.sh +70 -9
  4. package/gate-10.sh +110 -13
  5. package/gate-3.sh +17 -0
  6. package/gate-4.sh +12 -0
  7. package/gate-7.sh +4 -0
  8. package/hooks/gate-10.sh +110 -13
  9. package/hooks/gate-3.sh +17 -0
  10. package/hooks/gate-4.sh +12 -0
  11. package/hooks/gate-7.sh +4 -0
  12. package/hooks/hook-module-resolution.sh +14 -0
  13. package/hooks/lib/coverage-total.sh +1 -1
  14. package/hooks/pre-commit +138 -17
  15. package/hooks/pre-push +150 -9
  16. package/hooks/sprint-gate.sh +81 -2
  17. package/lib/__tests__/hook-health.test.js +85 -0
  18. package/lib/coverage-total.sh +92 -0
  19. package/lib/doctor.js +44 -0
  20. package/lib/hook-health.js +121 -0
  21. package/lib/mattpocock-license-verdict.json +1 -1
  22. package/mock-policy/AGENTS.md +2 -2
  23. package/mutation/AGENTS.md +2 -2
  24. package/mutation/__tests__/gate-m-survivors.test.ts +133 -0
  25. package/mutation/gate-m.ts +114 -11
  26. package/package.json +1 -1
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  31. package/plugins/dsh/package.json +1 -1
  32. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  33. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  34. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  43. package/principles/AGENTS.md +2 -2
  44. package/skills/delphi-review/AGENTS.md +2 -2
  45. package/skills/sprint-flow/AGENTS.md +2 -2
  46. package/skills/test-specification-alignment/AGENTS.md +2 -2
  47. package/sprint-gate.sh +81 -2
package/hooks/pre-commit CHANGED
@@ -1,4 +1,20 @@
1
1
  #!/bin/bash
2
+ # Line-ending self-check (#546). Must stay on the first executable line: if this
3
+ # file is ever rewritten with CRLF, bash needs to reach this check before the
4
+ # parser chokes somewhere far away and blames an innocent line. `tr` also
5
+ # strips the \r from this very line, so the check runs even when everything
6
+ # around it is broken.
7
+ if tr -d '\r' < "$0" | sed -n '2p' | tr -d '\r' | grep -q 'Line-ending self-check'; then
8
+ if [ "$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]')" -gt 0 ]; then
9
+ echo "======================================================================" >&2
10
+ echo "FATAL: hook has CRLF line endings — bash cannot parse it (#546)." >&2
11
+ echo " file: $0" >&2
12
+ echo " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" >&2
13
+ echo "Or re-sync the installed copy: xp-gate doctor --sync-hooks" >&2
14
+ echo "======================================================================" >&2
15
+ exit 1
16
+ fi
17
+ fi
2
18
  # OpenCode Quality Gates - Pre-Commit Hook - 12 numbered gates (Gate 0–11)
3
19
  #
4
20
  # DESIGN PRINCIPLE: Tool unavailable = SKIP (graceful degradation), tool available + check fails = BLOCK
@@ -41,6 +57,41 @@ fi
41
57
  # shellcheck source=/dev/null
42
58
  . "$HOOK_RESOLUTION_SH"
43
59
 
60
+ # --- Coverage threshold: ONE resolver, one number (#555) --------------------
61
+ # The threshold used to exist three times with two different values: the report
62
+ # printed a literal 80, coverage-total.sh compared against a literal 80, and the
63
+ # generic block branch echoed "below 80% threshold" — while the repo's own
64
+ # pyproject declared fail_under. CI honours the declared value, so a commit at
65
+ # 81% passed the hook (judged against 80) and failed CI (82). The report was the
66
+ # worst of the three: it quoted a threshold as though it were the enforced one,
67
+ # and a plausible wrong number is harder to notice than a visible gap.
68
+ #
69
+ # Resolve from the repo's own declaration, in the order the tools themselves
70
+ # use: pyproject [tool.coverage.report] fail_under, then .coveragerc. A repo
71
+ # declaring neither keeps the historical 80.
72
+ _resolve_coverage_threshold() {
73
+ _rct_value=""
74
+ _rct_src="default"
75
+ if [ -f pyproject.toml ]; then
76
+ _rct_value=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['tool']['coverage']['report']['fail_under'])" 2>/dev/null || true)
77
+ [ -n "$_rct_value" ] && _rct_src="pyproject.toml [tool.coverage.report] fail_under"
78
+ if [ -z "$_rct_value" ]; then
79
+ _rct_value=$(grep -oE '^[[:space:]]*fail_under[[:space:]]*=[[:space:]]*[0-9]+' pyproject.toml 2>/dev/null | grep -oE '[0-9]+' | head -1 || true)
80
+ [ -n "$_rct_value" ] && _rct_src="pyproject.toml fail_under (text scan)"
81
+ fi
82
+ fi
83
+ if [ -z "$_rct_value" ] && [ -f .coveragerc ]; then
84
+ _rct_value=$(grep -oE '^[[:space:]]*fail_under[[:space:]]*=[[:space:]]*[0-9]+' .coveragerc 2>/dev/null | grep -oE '[0-9]+' | head -1 || true)
85
+ [ -n "$_rct_value" ] && _rct_src=".coveragerc fail_under"
86
+ fi
87
+ case "$_rct_value" in
88
+ ''|*[!0-9]*) _rct_value="80"; _rct_src="default (no fail_under declared)" ;;
89
+ esac
90
+ COVERAGE_THRESHOLD_SOURCE="$_rct_src"
91
+ printf '%s' "$_rct_value"
92
+ }
93
+ COVERAGE_THRESHOLD="$(_resolve_coverage_threshold)"
94
+
44
95
  # Look up one tier's directory variable by name without eval-ing arbitrary input:
45
96
  # the base names are a closed set baked into the generated file.
46
97
  _hook_tier_base() {
@@ -1238,7 +1289,7 @@ fi
1238
1289
  GATE_1_START=$(gate_start_ms)
1239
1290
  GATE_1_STATUS="PASS"
1240
1291
  GATE_1_DETAIL=""
1241
- if ! any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|mm|dart|sh)$" "\.ya?ml$" "\.json$" "Dockerfile" "\.tf$"; then
1292
+ if ! any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|mm|dart|sh|ps1|psm1)$" "\.ya?ml$" "\.json$" "Dockerfile" "\.tf$"; then
1242
1293
  echo "✅ PASSED - No code files changed, skipping lint."
1243
1294
  GATE_1_STATUS="SKIP"
1244
1295
  GATE_1_DETAIL="no_matching_files"
@@ -1764,6 +1815,58 @@ else
1764
1815
  fi
1765
1816
  ;;
1766
1817
 
1818
+ powershell)
1819
+ # #547 finding 2: PowerShell had no arm here, so it fell into the generic
1820
+ # `*)` branch, where a non-zero analysis verdict printed a non-blocking
1821
+ # line and GATE_1_STATUS stayed PASS. The java arm's XP_G1_REASON
1822
+ # protocol applies verbatim: 0 = pass, 1 = block, 2 = unusable
1823
+ # (fail-closed), 3 = WARN with a reason that STAYS in the scoring
1824
+ # denominator so a skip is visible in retro instead of vanishing.
1825
+ POWERSHELL_ADAPTER=$(resolve_adapter_path "powershell")
1826
+ if [ -z "$POWERSHELL_ADAPTER" ]; then
1827
+ echo "⚠️ WARN - PowerShell adapter not found (code quality NOT verified)"
1828
+ GATE_1_STATUS="WARN"
1829
+ GATE_1_DETAIL="powershell: adapter missing"
1830
+ else
1831
+ PS_G1_EXIT=0
1832
+ # Subshell: reasons cross as `XP_G1_REASON: ...` stdout lines, exactly
1833
+ # as in the java arm — an export inside the adapter cannot reach us.
1834
+ PS_G1_OUTPUT=$(source "$POWERSHELL_ADAPTER" 2>/dev/null; run_static_analysis 2>&1) || PS_G1_EXIT=$?
1835
+ PS_G1_REASON=$(printf '%s\n' "$PS_G1_OUTPUT" | sed -n 's/^XP_G1_REASON: //p' | head -1)
1836
+ if [ -n "$PS_G1_REASON" ]; then
1837
+ PS_G1_OUTPUT=$(printf '%s\n' "$PS_G1_OUTPUT" | grep -v '^XP_G1_REASON: ')
1838
+ fi
1839
+ [ -n "$PS_G1_OUTPUT" ] && printf '%s\n' "$PS_G1_OUTPUT" | head -40
1840
+ case "$PS_G1_EXIT" in
1841
+ 0)
1842
+ echo "✅ PASSED - PowerShell code quality (PSScriptAnalyzer)."
1843
+ ;;
1844
+ 1)
1845
+ echo "❌ BLOCKED - PSScriptAnalyzer violations (fix or add suppressions)"
1846
+ GATE_1_STATUS="BLOCK"
1847
+ GATE_1_DETAIL="powershell: PSScriptAnalyzer findings"
1848
+ exit 1
1849
+ ;;
1850
+ 2)
1851
+ echo "❌ BLOCKED - PowerShell static analysis unusable (fail-closed): ${PS_G1_REASON:-unknown}"
1852
+ GATE_1_STATUS="BLOCK"
1853
+ GATE_1_DETAIL="powershell: ${PS_G1_REASON:-unusable}"
1854
+ exit 1
1855
+ ;;
1856
+ 3)
1857
+ echo "⚠️ WARN - PowerShell code quality NOT verified: ${PS_G1_REASON:-engine unavailable}"
1858
+ GATE_1_STATUS="WARN"
1859
+ GATE_1_DETAIL="powershell: ${PS_G1_REASON:-engine unavailable}"
1860
+ ;;
1861
+ *)
1862
+ echo "⚠️ WARN - PowerShell code quality NOT verified (unexpected verdict $PS_G1_EXIT)"
1863
+ GATE_1_STATUS="WARN"
1864
+ GATE_1_DETAIL="powershell: unexpected verdict $PS_G1_EXIT"
1865
+ ;;
1866
+ esac
1867
+ fi
1868
+ ;;
1869
+
1767
1870
  *)
1768
1871
  # Route to appropriate adapter for other languages
1769
1872
  ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
@@ -2711,11 +2814,18 @@ else
2711
2814
  PY_TARGET_COUNT=$(printf '%s\n' "$PY_TARGETS" | grep -c '.' || true)
2712
2815
  echo "ℹ️ Python: selective run ($PY_STRATEGY) — $PY_TARGET_COUNT target(s):"
2713
2816
  printf '%s\n' "$PY_TARGETS" | sed 's/^/ /'
2714
- # Subset run: keep --cov so data is collected, but drop the
2715
- # global threshold — subset coverage is meaningless globally.
2817
+ # Subset run: keep --cov so data is collected, but the global
2818
+ # threshold must be actively DISABLED, not merely omitted.
2819
+ # Omitting --cov-fail-under does not work (#556): pytest-cov
2820
+ # reads fail_under from pyproject on its own, so the floor
2821
+ # stayed in force, a selective run at 23% exited 1, and the
2822
+ # exit was escalated to a hard BLOCK -- printing "Partial
2823
+ # coverage ... not full suite" immediately above "BLOCKED -
2824
+ # Coverage check FAILED". --cov-fail-under=0 is the documented
2825
+ # way to switch it off for one invocation.
2716
2826
  PARTIAL_TEST_RUN=true
2717
2827
  # shellcheck disable=SC2086
2718
- pytest $PY_XDIST_ARGS $PY_COV_SOURCE --tb=short $PY_TARGETS
2828
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under=0 --tb=short $PY_TARGETS
2719
2829
  COV_EXIT=$((COV_EXIT || $?))
2720
2830
  fi
2721
2831
  fi
@@ -2731,7 +2841,7 @@ else
2731
2841
  go test -coverprofile=coverage.out ./... 2>/dev/null
2732
2842
  TOTAL_COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | grep "^total:" | awk '{print substr($3, 1, length($3)-1)}')
2733
2843
  if [ -n "$TOTAL_COVERAGE" ] && (( $(echo "$TOTAL_COVERAGE < 80" | bc -l 2>/dev/null || echo "0") )); then
2734
- echo "❌ BLOCKED - Go coverage $TOTAL_COVERAGE% below 80% threshold"
2844
+ echo "❌ BLOCKED - Go coverage $TOTAL_COVERAGE% below ${COVERAGE_THRESHOLD}% threshold"
2735
2845
  exit 1
2736
2846
  fi
2737
2847
  echo "Go coverage: $TOTAL_COVERAGE%"
@@ -2789,14 +2899,14 @@ else
2789
2899
  } catch(e) { console.log('parse_error'); }
2790
2900
  " 2>/dev/null)
2791
2901
  if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
2792
- if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2902
+ if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
2793
2903
  # Only a subset test run (smart selection) leaves partial coverage data;
2794
2904
  # a full suite run must block on <80% even when many test files changed.
2795
2905
  if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2796
2906
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2797
2907
  echo " Run 'npx vitest run --coverage' for full coverage check."
2798
2908
  else
2799
- echo "❌ BLOCKED - TypeScript coverage ${COVERAGE_PERCENT}% below 80% threshold"
2909
+ echo "❌ BLOCKED - TypeScript coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2800
2910
  exit 1
2801
2911
  fi
2802
2912
  else
@@ -2860,7 +2970,7 @@ else
2860
2970
  echo "⚠️ Partial coverage ${COVERAGE_ROUNDED}% (subset test run, not full suite)."
2861
2971
  echo " Run the full test suite for a complete Java coverage check."
2862
2972
  else
2863
- echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below 80% threshold"
2973
+ echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below ${COVERAGE_THRESHOLD}% threshold"
2864
2974
  exit 1
2865
2975
  fi
2866
2976
  else
@@ -2886,8 +2996,8 @@ else
2886
2996
  if [ -f "coverage/lcov.info" ]; then
2887
2997
  COVERAGE_PERCENT=$(parse_lcov_coverage "coverage/lcov.info" 2>/dev/null) || COVERAGE_PERCENT="0"
2888
2998
  if [ "$COVERAGE_PERCENT" != "0" ] && [ -n "$COVERAGE_PERCENT" ]; then
2889
- if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2890
- echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below 80% threshold"
2999
+ if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
3000
+ echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2891
3001
  exit 1
2892
3002
  fi
2893
3003
  echo "Flutter/Dart coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
@@ -2926,7 +3036,7 @@ else
2926
3036
  COVERAGE_STATUS=${COVERAGE_RESULT%%|*}
2927
3037
  COVERAGE_PERCENT=${COVERAGE_RESULT#*|}
2928
3038
  if [ "$COVERAGE_STATUS" = "below" ]; then
2929
- echo "❌ BLOCKED - PowerShell coverage ${COVERAGE_PERCENT}% below 80% threshold"
3039
+ echo "❌ BLOCKED - PowerShell coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2930
3040
  exit 1
2931
3041
  fi
2932
3042
  echo "PowerShell coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
@@ -2963,14 +3073,14 @@ else
2963
3073
  fi
2964
3074
  fi
2965
3075
  if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
2966
- if [ "$COVERAGE_PERCENT" -lt 80 ]; then
3076
+ if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
2967
3077
  # Only a subset test run (smart selection) warns — the full suite may
2968
3078
  # still reach ≥80%; a full suite run must block.
2969
3079
  if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2970
3080
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2971
3081
  echo " Run the full test suite for a complete ${CURRENT_LANG} coverage check."
2972
3082
  else
2973
- echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
3083
+ echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2974
3084
  exit 1
2975
3085
  fi
2976
3086
  else
@@ -3019,10 +3129,15 @@ else
3019
3129
  }
3020
3130
  } catch(e) { console.log('parse_error'); }
3021
3131
  " 2>/dev/null)
3132
+ # NOTE (#555): the 80 below is deliberately NOT $COVERAGE_THRESHOLD.
3133
+ # That one is the project-level floor a repo declares in fail_under;
3134
+ # this is the bar a single NEW file must clear to justify existing at
3135
+ # all. Tying them would mean a repo with fail_under=82 could not add a
3136
+ # file at 81% even while the project sits well above its floor.
3022
3137
  if [ "$FILE_COV" = "no_data" ] || [ "$FILE_COV" = "parse_error" ]; then
3023
3138
  echo " ⚠️ $new_file — no coverage data (file may be excluded or not exercised)"
3024
3139
  elif [ "$FILE_COV" -lt 80 ]; then
3025
- echo " ❌ $new_file — ${FILE_COV}% coverage (below 80% threshold)"
3140
+ echo " ❌ $new_file — ${FILE_COV}% coverage (below 80% per-new-file threshold)"
3026
3141
  NEW_FILE_COV_FAIL=true
3027
3142
  else
3028
3143
  echo " ✅ $new_file — ${FILE_COV}% coverage"
@@ -3036,8 +3151,13 @@ else
3036
3151
  fi
3037
3152
  fi
3038
3153
  fi
3154
+ # Only reached on the non-docs-only path. Assigning PASS outside the if/else
3155
+ # overwrote the docs-only SKIP a few hundred lines above and reported
3156
+ # "Tests + Coverage: PASS (coverage: N/A)" for a commit where no test ran
3157
+ # (#554) -- a SKIP promoted to PASS inflates the pass rate and hides that the
3158
+ # gate never executed.
3159
+ GATE_5_STATUS="PASS"
3039
3160
  fi
3040
- GATE_5_STATUS="PASS"
3041
3161
  record_gate_audit "gate-5" "tests-coverage" "$GATE_5_STATUS" "0" "$GATE_5_START" "${GATE_5_DETAIL:-}"
3042
3162
 
3043
3163
  # ============================================================================
@@ -3582,7 +3702,7 @@ echo ""
3582
3702
  # v0.14.3+ (Issue #312): Skip if no code files changed
3583
3703
  # v0.18.5 (Issue #397): Fix gate status variable collision — SAST now sets GATE_10_STATUS
3584
3704
  # ============================================================================
3585
- if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh)$"; then
3705
+ if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh|ps1|psm1)$"; then
3586
3706
  if [ -f "$GATE_DIR/gate-10.sh" ]; then
3587
3707
  source "$GATE_DIR/gate-10.sh"
3588
3708
  else
@@ -3885,7 +4005,8 @@ generate_quality_report() {
3885
4005
  "gate5_tests": {
3886
4006
  "name": "Tests + Coverage",
3887
4007
  "status": "${GATE_5_STATUS:-PASS}",
3888
- "thresholds": { "lines": 80, "functions": 80, "branches": 70, "statements": 80 },
4008
+ "thresholds": { "lines": ${COVERAGE_THRESHOLD}, "functions": null, "branches": null, "statements": null },
4009
+ "threshold_source": "${COVERAGE_THRESHOLD_SOURCE}",
3889
4010
  "actual": { "coverage": "${COV_PCT}" },
3890
4011
  "failedTests": $FAILED_TESTS_JSON
3891
4012
  },
package/hooks/pre-push CHANGED
@@ -1,4 +1,20 @@
1
1
  #!/bin/bash
2
+ # Line-ending self-check (#546). Must stay on the first executable line: if this
3
+ # file is ever rewritten with CRLF, bash needs to reach this check before the
4
+ # parser chokes somewhere far away and blames an innocent line. `tr` also
5
+ # strips the \r from this very line, so the check runs even when everything
6
+ # around it is broken.
7
+ if tr -d '\r' < "$0" | sed -n '2p' | tr -d '\r' | grep -q 'Line-ending self-check'; then
8
+ if [ "$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]')" -gt 0 ]; then
9
+ echo "======================================================================" >&2
10
+ echo "FATAL: hook has CRLF line endings — bash cannot parse it (#546)." >&2
11
+ echo " file: $0" >&2
12
+ echo " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" >&2
13
+ echo "Or re-sync the installed copy: xp-gate doctor --sync-hooks" >&2
14
+ echo "======================================================================" >&2
15
+ exit 1
16
+ fi
17
+ fi
2
18
  # Pre-push Hook — 8 gates: Gate 10 (Build Integrity) + M/M-Python/M-Go/M-Java/M-Kotlin (Mutation)
3
19
  # + M2 (Mock Density, WARNING) + ML (Mock Layering) + UI (UI Sprint Gates) + MW (Code Walkthrough) + S (Sprint Flow)
4
20
  #
@@ -31,11 +47,28 @@ write_pre_push_report() {
31
47
  commit_hash=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
32
48
  verdict=$([ "$exit_code" -eq 0 ] && echo "PASS" || echo "BLOCKED")
33
49
  mkdir -p "$reports_dir"
50
+ # #544: the survivor detail is a JSON document, so it cannot be interpolated
51
+ # into this heredoc — a single quote in a path would break the Python source.
52
+ # Hand it to the child through the environment and let Python parse it, then
53
+ # store it serialised back into the `details` string domain (every value there
54
+ # is a string; the journal has broken before over mixed types).
55
+ export XP_GATE_M_SURVIVORS_JSON="${M_SURVIVORS_JSON:-}"
34
56
  python3 - <<PY 2>/dev/null || true
35
57
  import json
58
+ import os
36
59
  from pathlib import Path
37
60
  pushed_files = '''${PUSHED_FILES:-}'''.splitlines()
38
61
  passed = 1 if '$verdict' == 'PASS' else 0
62
+ # Absent stays absent: no key at all when nothing was measured, so a reader can
63
+ # tell "not measured" from "measured: zero survivors" (#544).
64
+ try:
65
+ _survivors_raw = os.environ.get('XP_GATE_M_SURVIVORS_JSON', '').strip()
66
+ survivors_detail = json.loads(_survivors_raw) if _survivors_raw else None
67
+ except (ValueError, TypeError):
68
+ survivors_detail = None
69
+ _m_details = {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}
70
+ if survivors_detail is not None:
71
+ _m_details['survivors'] = json.dumps(survivors_detail, sort_keys=True)
39
72
  report = {
40
73
  'timestamp': '$timestamp',
41
74
  'trigger': 'pre-push',
@@ -51,15 +84,20 @@ report = {
51
84
  'gates': [
52
85
  {'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},
53
86
  {'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
54
- {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}},
55
- {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PYTHON_STATUS:-SKIP}', 'details': {}},
87
+ {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': _m_details},
88
+ # #557 follow-up: this line read GATE_M_PYTHON_STATUS while the code assigns
89
+ # GATE_M_PY_STATUS, so the field always fell back to its SKIP default -- the
90
+ # "never write the real state" bug #557 set out to fix, still present for
91
+ # this one gate. The skip reason now travels with it, so a retro reader can
92
+ # tell "no Python here" from "timed out" from "mutmut missing".
93
+ {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PY_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_PY_SKIP_REASON:-}'}},
56
94
  {'id': 'M-Go', 'name': 'Mutation Testing (Go)', 'status': '${GATE_M_GO_STATUS:-SKIP}', 'details': {}},
57
95
  {'id': 'M-Java', 'name': 'Mutation Testing (Java)', 'status': '${GATE_M_JAVA_STATUS:-SKIP}', 'details': {}},
58
96
  {'id': 'M-Kotlin', 'name': 'Mutation Testing (Kotlin)', 'status': '${GATE_M_KOTLIN_STATUS:-SKIP}', 'details': {}},
59
97
  {'id': 'M2', 'name': 'Mock Density (WARNING only)', 'status': '${GATE_M2_STATUS:-SKIP}', 'details': {}},
60
98
  {'id': 'ML', 'name': 'Mock Layering', 'status': '${GATE_M3_STATUS:-SKIP}', 'details': {}},
61
99
  {'id': 'UI', 'name': 'UI Sprint Gates', 'status': '${GATE_UI_STATUS:-SKIP}', 'details': {}},
62
- {'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL'}},
100
+ {'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL', 'skip_reason': '${GATE_DELPHI_SKIP_REASON:-}'}},
63
101
  {'id': 'S', 'name': 'Sprint Flow Enforcement', 'status': '${GATE_S_STATUS:-SKIP}', 'details': {}}
64
102
  ],
65
103
  'warnings': [],
@@ -220,6 +258,10 @@ if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
220
258
  fi
221
259
  else
222
260
  echo "⚠️ Gate MP: check-hook-mirror.sh not found. SKIP — Gate MP."
261
+ echo " Gate MP did not run; this push was not checked for mirror drift."
262
+ GATE_MP_STATUS="SKIP"
263
+ GATE_MP_SKIP_REASON="mirror_script_not_found"
264
+ GATES_SKIPPED="${GATES_SKIPPED:-}Gate MP (mirror parity: script not found)|"
223
265
  MP_DURATION_MS=0
224
266
  fi
225
267
  echo ""
@@ -433,6 +475,15 @@ else
433
475
  # It is printed by gate-m.ts; take the last percentage it reported.
434
476
  M_SCORE=$(sed -n 's/.*[Mm]utation score[: ]*\([0-9][0-9]*\(\.[0-9][0-9]*\)\?\)%.*/\1/p' "$MUTATION_OUTPUT" 2>/dev/null | tail -1)
435
477
 
478
+ # #544: per-file survivor detail, so retro can answer "which module has
479
+ # the most surviving mutants". gate-m.ts emits one
480
+ # GATE_M_SURVIVORS_JSON= line; it is ABSENT when no report was produced,
481
+ # and absent must stay absent. An empty value means "not measured"; a
482
+ # written zero would mean "measured, no survivors". Only the former is
483
+ # ever true when the line is missing -- conflating the two is the whole
484
+ # of #544.
485
+ M_SURVIVORS_JSON=$(printf '%s\n' "$MUTATION_OUTPUT" | sed -n 's/^GATE_M_SURVIVORS_JSON=//p' | tail -1)
486
+
436
487
  case $MUTATION_EXIT in
437
488
  0)
438
489
  echo "✅ Gate M: PASS"
@@ -491,6 +542,13 @@ PY_SOURCE_FILES=$(echo "$PY_FILES" | grep -v '/venv/' | grep -v '/\.venv/' || tr
491
542
 
492
543
  if [ -z "$PY_SOURCE_FILES" ]; then
493
544
  echo "📚 No changed Python source files. SKIP — Gate M (Python)."
545
+ # #557: this outermost branch was the only Gate M (Python) path that assigned
546
+ # no status, while Gate M (TypeScript) records a reason on every path. Without
547
+ # it, "no Python in this push" and "the tool is missing" look identical in the
548
+ # journals, which is the gap #404 P0 spent effort closing for the TS gate.
549
+ GATE_M_PY_STATUS="SKIP"
550
+ GATE_M_PY_SKIP_REASON="no_python_source_files"
551
+ GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (no changed Python source files)|"
494
552
  else
495
553
  PYTHON_FILE_LIST=$(echo "$PY_SOURCE_FILES" | tr '\n' ',' | sed 's/,$//')
496
554
  echo "🐍 Changed Python files: $PYTHON_FILE_LIST"
@@ -504,6 +562,7 @@ else
504
562
  case $MUTATION_EXIT in
505
563
  0)
506
564
  echo "✅ Gate M (Python): PASS"
565
+ GATE_M_PY_STATUS="PASS"
507
566
  ;;
508
567
  1)
509
568
  echo ""
@@ -519,16 +578,28 @@ else
519
578
  124)
520
579
  cat "$MUTATION_OUTPUT"
521
580
  echo "⏱ Gate M (Python): TIMEOUT (120s). Allowing push with warning."
581
+ # A timeout means the mutation score was never measured. Saying
582
+ # nothing here let the closing banner claim success for a gate that
583
+ # started work and could not finish (#557).
584
+ GATE_M_PY_STATUS="TIMEOUT"
585
+ GATE_M_PY_SKIP_REASON="timeout_120s"
586
+ GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (TIMEOUT — score never measured)|"
522
587
  ;;
523
588
  *)
524
589
  cat "$MUTATION_OUTPUT"
525
590
  echo "⚠ Gate M (Python): Unexpected exit code $MUTATION_EXIT. Allowing push with warning."
591
+ GATE_M_PY_STATUS="ERROR"
592
+ GATE_M_PY_SKIP_REASON="unexpected_exit_${MUTATION_EXIT}"
593
+ GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (error, exit ${MUTATION_EXIT} — score never measured)|"
526
594
  ;;
527
595
  esac
528
596
 
529
597
  rm -f "$MUTATION_OUTPUT"
530
598
  else
531
599
  echo "⚠ Gate M script not found. SKIP — Gate M (Python)."
600
+ GATE_M_PY_STATUS="SKIP"
601
+ GATE_M_PY_SKIP_REASON="gate_m_script_not_found"
602
+ GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (gate-m.ts not found)|"
532
603
  fi
533
604
  else
534
605
  echo "⚠ mutmut not installed. SKIP — Gate M (Python)."
@@ -929,11 +1000,51 @@ if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
929
1000
  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\"}"
930
1001
  echo "$AUDIT_ENTRY" >> .audit-log.jsonl
931
1002
  else
932
- # Detect UI changes in pushed files
933
- UI_DETECTION=$(echo "$PUSHED_FILES" | npx -y tsx src/npm-package/lib/ui-detector.ts --push-mode --from-stdin 2>/dev/null) || true
934
-
935
- IS_UI_SPRINT=$(echo "$UI_DETECTION" | jq -r '.isUiSprint' 2>/dev/null || echo "false")
936
-
1003
+ # Detect UI changes in pushed files.
1004
+ #
1005
+ # stderr is kept out of the captured value but its ABSENCE is no longer
1006
+ # treated as a clean "no UI here" answer (#553). jq -r prints the empty
1007
+ # string and exits 0 on empty input, so the old `|| echo "false"` fallback
1008
+ # was unreachable; an empty verdict became "", which is not "true", which
1009
+ # silently took the "No UI changes detected — skipping" branch. A push whose
1010
+ # only change was src/ui/streamlit_app.py was therefore told there was
1011
+ # nothing to review — the failure Gate UI exists to prevent, inverted.
1012
+ UI_DETECTION=$(echo "$PUSHED_FILES" | npx -y tsx src/npm-package/lib/ui-detector.ts --push-mode --from-stdin 2>/dev/null)
1013
+ UI_DETECT_EXIT=$?
1014
+
1015
+ if [ "$UI_DETECT_EXIT" -eq 0 ] && [ -n "$UI_DETECTION" ]; then
1016
+ IS_UI_SPRINT=$(echo "$UI_DETECTION" | jq -r 'if type == "object" then (.isUiSprint | tostring) else "unknown" end' 2>/dev/null)
1017
+ else
1018
+ IS_UI_SPRINT=""
1019
+ fi
1020
+ # Three answers, not two. "" (detector missing, crashed, or emitted nothing)
1021
+ # is "unknown", and unknown must never read as "no UI".
1022
+ if [ -z "$IS_UI_SPRINT" ] || [ "$IS_UI_SPRINT" = "null" ]; then
1023
+ IS_UI_SPRINT="unknown"
1024
+ fi
1025
+
1026
+ if [ "$IS_UI_SPRINT" = "unknown" ]; then
1027
+ # A missing detector must not disable UI review. Fall back to a path
1028
+ # check over the same file list: a cheap, conservative substitute for a
1029
+ # verdict we do not have.
1030
+ UI_PATH_HITS=$(echo "$PUSHED_FILES" | grep -Ei '(^|/)(src/ui|ui|components|pages|views|screens)/|[.](tsx|jsx|vue|svelte|css|scss|sass|less)$' || true)
1031
+ if [ -n "$UI_PATH_HITS" ]; then
1032
+ echo ""
1033
+ echo "❌ Gate UI: could not evaluate the UI verdict (detector unavailable or produced no verdict, exit ${UI_DETECT_EXIT})."
1034
+ echo " This push touches UI-looking paths:"
1035
+ echo "$UI_PATH_HITS" | sed 's/^/ /'
1036
+ echo ""
1037
+ echo " The gate cannot claim there is nothing to review when it never got an answer."
1038
+ echo " Fix: install the detector (npx -y tsx must be able to load src/npm-package/lib/ui-detector.ts),"
1039
+ echo " or run the review explicitly: xp-gate ui-review"
1040
+ echo " To accept the risk for this push: XP_GATE_SKIP_UI_GATES=1 XP_GATE_BYPASS_REASON=\"<why>\""
1041
+ echo ""
1042
+ exit 1
1043
+ fi
1044
+ echo "ℹ️ Gate UI: detector unavailable (exit ${UI_DETECT_EXIT}) and no UI-looking paths in this push — nothing to review."
1045
+ IS_UI_SPRINT="false"
1046
+ fi
1047
+
937
1048
  if [[ "$IS_UI_SPRINT" == "true" ]]; then
938
1049
  UI_RESULT_FILE=".ui-gate-result.json"
939
1050
  MISSING_RESULTS=()
@@ -986,6 +1097,15 @@ else
986
1097
  # ============================================================================
987
1098
  RESULT_FILE=".code-walkthrough-result.json"
988
1099
  MW_SKIPPED=0
1100
+ # Journal status (#557 follow-up). The journal already read GATE_DELPHI_STATUS
1101
+ # but nothing ever assigned it, so Gate MW reported its SKIP default in every
1102
+ # report -- including pushes where the walkthrough was in fact verified. The
1103
+ # closing banner told the truth while the machine-readable journal did not, which
1104
+ # is the half-fix #557 was about.
1105
+ GATE_DELPHI_STATUS="SKIP"
1106
+ # Consumed by the journal heredoc (sourced/inherited boundary).
1107
+ # shellcheck disable=SC2034
1108
+ GATE_DELPHI_SKIP_REASON="not_run"
989
1109
 
990
1110
  # Skipped on main/master by design, exactly like Gate ML and Gate UI above.
991
1111
  # Work is already walkthrough-reviewed on the feature branch before it lands, so
@@ -995,6 +1115,9 @@ MW_SKIPPED=0
995
1115
  # documented this skip; the code did not implement it (drift fixed here).
996
1116
  if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
997
1117
  MW_SKIPPED=1
1118
+ # Consumed by the journal heredoc (sourced/inherited boundary).
1119
+ # shellcheck disable=SC2034
1120
+ GATE_DELPHI_SKIP_REASON="main_branch_push"
998
1121
  echo ""
999
1122
  echo " ℹ️ Gate MW skipped (pushing to ${CURRENT_BRANCH} — pre-reviewed via branch walkthrough)"
1000
1123
  echo ""
@@ -1064,13 +1187,31 @@ if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "
1064
1187
  echo ""
1065
1188
  exit 1
1066
1189
  fi
1190
+
1191
+ # The validator passed: record that the walkthrough was actually verified.
1192
+ # Consumed by the journal heredoc (sourced/inherited boundary).
1193
+ # shellcheck disable=SC2034
1194
+ GATE_DELPHI_STATUS="PASS"
1195
+ # shellcheck disable=SC2034
1196
+ GATE_DELPHI_SKIP_REASON=""
1067
1197
  fi
1068
1198
 
1069
1199
  # ============================================================================
1070
- # ALL CHECKS PASSED
1200
+ # Closing banner. States what actually happened (#557): a gate that skipped is
1201
+ # named. The heading used to read "ALL CHECKS PASSED" unconditionally while Gate
1202
+ # MP and Gate M (Python) skipped on a typical push without recording why, so a
1203
+ # reviewer skimming the log had no signal that mutation testing never ran.
1071
1204
  # ============================================================================
1072
1205
  echo ""
1073
1206
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1207
+ # Trim the trailing separator and turn it into a displayable list.
1208
+ SKIPPED_DISPLAY=$(printf '%s' "${GATES_SKIPPED:-}" | sed 's/|$//' | sed 's/|/ , /g')
1209
+ if [ -n "$SKIPPED_DISPLAY" ]; then
1210
+ echo " ⚠️ NO BLOCKING FAILURES — but not every gate ran:"
1211
+ echo "$SKIPPED_DISPLAY" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | sed 's/^[[:space:]]*/ - /'
1212
+ else
1213
+ echo " ✅ ALL CHECKS PASSED"
1214
+ fi
1074
1215
  # Say what actually happened. Printing "VERIFIED" on a skipped gate would be a
1075
1216
  # claim the hook cannot support — the same dishonesty (evidence asserting an
1076
1217
  # unperformed check) that Gate MW exists to prevent.
@@ -181,6 +181,65 @@ read_verdict() {
181
181
  json_extract "$APPROVED_FILE" '.verdict // ""' || echo ""
182
182
  }
183
183
 
184
+ # --- Helper: sprint age in days, or empty if unknown ---
185
+ # Reads status + started_at from sprint-state.json. #551: the gate judged only
186
+ # `phase` and the delphi verdict, so a state file left at status=in_progress
187
+ # reported "sprint phase 6 validated" on unrelated commits for as long as
188
+ # nobody fixed it. An advancing sprint and an abandoned one were
189
+ # indistinguishable -- worse than SKIP, because SKIP is honest and PASS lies.
190
+ read_sprint_status() {
191
+ if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
192
+ echo ""
193
+ return
194
+ fi
195
+ json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
196
+ json_extract "$SPRINT_STATE_JSON" '.status // ""' 2>/dev/null || echo ""
197
+ }
198
+
199
+ read_sprint_age_days() {
200
+ if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
201
+ echo ""
202
+ return
203
+ fi
204
+ json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
205
+ local started
206
+ started=$(json_extract "$SPRINT_STATE_JSON" '.started_at // ""' 2>/dev/null || echo "")
207
+ # Older state files may carry updated_at only; either is the last sign of life.
208
+ if [ -z "$started" ] || [ "$started" = "null" ]; then
209
+ started=$(json_extract "$SPRINT_STATE_JSON" '.updated_at // ""' 2>/dev/null || echo "")
210
+ fi
211
+ if [ -z "$started" ] || [ "$started" = "null" ]; then
212
+ echo ""
213
+ return
214
+ fi
215
+ # Portable ISO-8601 -> epoch. GNU date first, BSD date second, node last; if
216
+ # all fail the age is unknown and the caller must not invent one.
217
+ local epoch=""
218
+ epoch=$(date -u -d "$started" +%s 2>/dev/null || true)
219
+ if [ -z "$epoch" ]; then
220
+ epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$started" +%s 2>/dev/null || true)
221
+ fi
222
+ if [ -z "$epoch" ] && command -v node >/dev/null 2>&1; then
223
+ epoch=$(node -e "const t=Date.parse(process.argv[1]);if(Number.isFinite(t))process.stdout.write(String(Math.floor(t/1000)))" "$started" 2>/dev/null || true)
224
+ fi
225
+ if [ -z "$epoch" ]; then
226
+ echo ""
227
+ return
228
+ fi
229
+ local now
230
+ now=$(date -u +%s)
231
+ local age=$(( (now - epoch) / 86400 ))
232
+ [ "$age" -lt 0 ] && age=0
233
+ echo "$age"
234
+ }
235
+
236
+ # Threshold in days beyond which an in_progress sprint is considered abandoned.
237
+ # Configurable because the right number is project-specific: a weekly cadence
238
+ # wants 14, a monthly one wants more. Default 14 = two full weeks without
239
+ # progress: enough that a sprint paused over a holiday is not called dead, tight
240
+ # enough that the reported 12-day case is caught within two days of it firing.
241
+ SPRINT_STALE_DAYS="${SPRINT_STALE_DAYS:-14}"
242
+
184
243
  # --- Pre-commit mode ---
185
244
  if [ "$MODE" = "pre-commit" ]; then
186
245
  PHASE=$(read_phase)
@@ -222,8 +281,28 @@ if [ "$MODE" = "pre-commit" ]; then
222
281
  exit 1
223
282
  fi
224
283
 
225
- # All checks passed
226
- echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated\"}"
284
+ # #551: staleness. A sprint left at status=in_progress past the threshold is
285
+ # abandoned, not validated -- reporting PASS for it means the gate cannot
286
+ # distinguish an advancing sprint from a forgotten one, and it injects
287
+ # ancient phase_history into every unrelated commit's audit trail. SKIP is
288
+ # the honest answer: the gate declines to certify a sprint nobody is
289
+ # driving, without blocking work that has nothing to do with it.
290
+ SPRINT_STATUS=$(read_sprint_status)
291
+ SPRINT_AGE_DAYS=$(read_sprint_age_days)
292
+ if [ "$SPRINT_STATUS" = "in_progress" ] && [ -n "$SPRINT_AGE_DAYS" ] \
293
+ && [ "$SPRINT_AGE_DAYS" -ge "$SPRINT_STALE_DAYS" ] 2>/dev/null; then
294
+ echo "{\"decision\":\"skip\",\"reason\":\"STALE SPRINT: status=in_progress for ${SPRINT_AGE_DAYS} days (threshold ${SPRINT_STALE_DAYS}) — not certifying it. Finish it (sprint-flow close) or clear .sprint-state/sprint-state.json\"}"
295
+ exit 0
296
+ fi
297
+
298
+ # All checks passed. Name the age so "validated" is not an unconditional
299
+ # claim: a 29-day-old sprint under a 30-day threshold is a very different
300
+ # situation from one opened today, and the log should say which it is.
301
+ if [ -n "$SPRINT_AGE_DAYS" ]; then
302
+ echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint open ${SPRINT_AGE_DAYS} day(s), staleness threshold ${SPRINT_STALE_DAYS})\"}"
303
+ else
304
+ echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint age unknown — started_at unreadable)\"}"
305
+ fi
227
306
  exit 0
228
307
  fi
229
308