@boyingliu01/xp-gate 0.30.1 → 0.30.3
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/adapters/iac.sh +45 -4
- package/adapters/java.sh +82 -16
- package/adapters/powershell.sh +70 -9
- package/bin/xp-gate.js +8 -0
- package/gate-10.sh +32 -2
- package/gate-3.sh +47 -6
- package/gate-4.sh +12 -0
- package/gate-7.sh +4 -0
- package/hooks/gate-10.sh +32 -2
- package/hooks/gate-3.sh +47 -6
- package/hooks/gate-4.sh +12 -0
- package/hooks/gate-7.sh +4 -0
- package/hooks/hook-module-resolution.sh +14 -0
- package/hooks/lib/coverage-total.sh +1 -1
- package/hooks/pre-commit +138 -17
- package/hooks/pre-push +97 -6
- package/hooks/sprint-gate.sh +81 -2
- package/lib/__tests__/crap-cli.test.js +76 -0
- package/lib/__tests__/crap.test.js +137 -0
- package/lib/__tests__/hook-health.test.js +85 -0
- package/lib/__tests__/retro-gate-m.test.js +144 -0
- package/lib/coverage-total.sh +92 -0
- package/lib/crap-cli.js +191 -0
- package/lib/crap.js +196 -0
- package/lib/doctor.js +44 -0
- package/lib/hook-health.js +121 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +139 -2
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/sprint-gate.sh +81 -2
package/hooks/gate-10.sh
CHANGED
|
@@ -12,7 +12,20 @@ GATE_10_START=$(gate_start_ms)
|
|
|
12
12
|
GATE_10_STATUS=""
|
|
13
13
|
# #536: audit trail for "did this commit actually get scanned?". The report
|
|
14
14
|
# surfaces reason + scanned_files so a non-scan never reads as a clean PASS.
|
|
15
|
+
#
|
|
16
|
+
# GATE_10_REASON and GATE_10_SCANNED_FILES are consumed by the sourcing parent
|
|
17
|
+
# (pre-commit builds its report JSON from them after `source gate-10.sh`), not
|
|
18
|
+
# inside this file. The linter checks one file at a time and cannot see the
|
|
19
|
+
# consumer, so each assignment is flagged SC2034 "appears unused" -- which
|
|
20
|
+
# blocks Gate 1 for any repo that vendors this adapter, because that repo's own
|
|
21
|
+
# lint pass runs over the vendored copy (#548). Each assignment carries a
|
|
22
|
+
# scoped directive with that reason stated at the site.
|
|
23
|
+
#
|
|
24
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
25
|
+
# shellcheck disable=SC2034
|
|
15
26
|
GATE_10_REASON=""
|
|
27
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
28
|
+
# shellcheck disable=SC2034
|
|
16
29
|
GATE_10_SCANNED_FILES=0
|
|
17
30
|
|
|
18
31
|
# Semgrep availability check
|
|
@@ -29,14 +42,21 @@ if [ -z "$SEMGREP_CMD" ]; then
|
|
|
29
42
|
echo " Pre-cache rules: semgrep --config=p/security-audit"
|
|
30
43
|
echo " Gate 10: SAST Security (WARN, semgrep not installed)"
|
|
31
44
|
GATE_10_STATUS="WARN"
|
|
45
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
46
|
+
# shellcheck disable=SC2034
|
|
32
47
|
GATE_10_REASON="tool_not_installed"
|
|
33
48
|
else
|
|
34
|
-
# Get staged files filtered to Semgrep-supported languages
|
|
35
|
-
|
|
49
|
+
# Get staged files filtered to Semgrep-supported languages.
|
|
50
|
+
# #547 finding 3: ps1|psm1 were missing, so a PowerShell-only commit never
|
|
51
|
+
# reached semgrep even when Gate 10 ran -- the SAST gate was simply absent
|
|
52
|
+
# for that language.
|
|
53
|
+
SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift|ps1|psm1)$' || true)
|
|
36
54
|
|
|
37
55
|
if [ -z "$SEMGREP_FILES" ]; then
|
|
38
56
|
echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
|
|
39
57
|
GATE_10_STATUS="SKIP"
|
|
58
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
59
|
+
# shellcheck disable=SC2034
|
|
40
60
|
GATE_10_REASON="no_supported_files"
|
|
41
61
|
else
|
|
42
62
|
# One path per line into an array. Quoting the newline-joined list would hand
|
|
@@ -91,6 +111,8 @@ EOF
|
|
|
91
111
|
echo " Install node (https://nodejs.org); without it SAST findings cannot be classified"
|
|
92
112
|
echo " ⏭️ SKIPPED - SAST (node not installed)"
|
|
93
113
|
GATE_10_STATUS="SKIP"
|
|
114
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
115
|
+
# shellcheck disable=SC2034
|
|
94
116
|
GATE_10_REASON="tool_not_installed"
|
|
95
117
|
else
|
|
96
118
|
|
|
@@ -164,6 +186,8 @@ console.log("");});
|
|
|
164
186
|
fi
|
|
165
187
|
echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
|
|
166
188
|
GATE_10_STATUS="SKIP"
|
|
189
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
190
|
+
# shellcheck disable=SC2034
|
|
167
191
|
GATE_10_REASON="output_unparseable"
|
|
168
192
|
elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
|
|
169
193
|
# Nothing was scanned to a verdict: semgrep either failed outright
|
|
@@ -178,6 +202,8 @@ console.log("");});
|
|
|
178
202
|
fi
|
|
179
203
|
echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
|
|
180
204
|
GATE_10_STATUS="SKIP"
|
|
205
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
206
|
+
# shellcheck disable=SC2034
|
|
181
207
|
GATE_10_REASON="semgrep_runtime_error"
|
|
182
208
|
elif [ "$V_BLOCKING" -gt 0 ]; then
|
|
183
209
|
echo ""
|
|
@@ -192,9 +218,13 @@ console.log("");});
|
|
|
192
218
|
echo "$FINDING_DETAILS"
|
|
193
219
|
echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
|
|
194
220
|
GATE_10_STATUS="FAIL"
|
|
221
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
222
|
+
# shellcheck disable=SC2034
|
|
195
223
|
GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
|
|
196
224
|
exit 1
|
|
197
225
|
else
|
|
226
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
227
|
+
# shellcheck disable=SC2034
|
|
198
228
|
GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
|
|
199
229
|
echo ""
|
|
200
230
|
echo " ✅ PASSED - No blocking vulnerabilities (files scanned: $SEMGREP_FILE_COUNT)"
|
package/hooks/gate-3.sh
CHANGED
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
2>&1 echo "→ Gate 3: Cyclomatic complexity..."
|
|
9
9
|
GATE_3_START=$(gate_start_ms)
|
|
10
10
|
# #537: every non-run records WHY so the report can say why a gate is SKIP.
|
|
11
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
12
|
+
# shellcheck disable=SC2034
|
|
11
13
|
GATE_3_REASON=""
|
|
12
14
|
|
|
13
15
|
if [ "$PROJECT_LANG" = "documentation-only" ]; then
|
|
14
16
|
echo "⏭️ SKIPPED - Complexity (documentation project)."
|
|
15
17
|
GATE_3_STATUS="SKIP"
|
|
18
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
19
|
+
# shellcheck disable=SC2034
|
|
16
20
|
GATE_3_REASON="documentation_project"
|
|
17
21
|
|
|
18
22
|
elif [ "$PROJECT_LANG" = "powershell" ]; then
|
|
@@ -21,6 +25,8 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
|
|
|
21
25
|
GATE_3_STATUS="SKIP"
|
|
22
26
|
# #537: a PowerShell-primary repo was invisible to this dimension while the
|
|
23
27
|
# score implied full coverage — say exactly why nothing ran.
|
|
28
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
29
|
+
# shellcheck disable=SC2034
|
|
24
30
|
GATE_3_REASON="no_powershell_tool"
|
|
25
31
|
|
|
26
32
|
else
|
|
@@ -159,7 +165,10 @@ else
|
|
|
159
165
|
# empty stdin — the verdict comes back empty and Gate 3 passes on zero
|
|
160
166
|
# rows (measured while wiring #544: same input passed or blocked
|
|
161
167
|
# depending only on where the prefix sat).
|
|
168
|
+
# Consumed by evaluate_ccn_ratchet in the sourced lib, not here.
|
|
169
|
+
# shellcheck disable=SC2034
|
|
162
170
|
CCN_WARN_THRESHOLD="$CCN_THRESHOLD"
|
|
171
|
+
# shellcheck disable=SC2034
|
|
163
172
|
CCN_BLOCK_THRESHOLD=$((CCN_THRESHOLD * 2))
|
|
164
173
|
RATCHET_VERDICT=$(printf '%s\n' "$CC_CSV" | evaluate_ccn_ratchet ".ccn-baseline.json")
|
|
165
174
|
CC_VIOLATIONS=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^BLOCK ' || true)
|
|
@@ -179,16 +188,44 @@ else
|
|
|
179
188
|
GATE_3_STATUS="PASS"
|
|
180
189
|
fi
|
|
181
190
|
else
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
# #543: count from lizard's machine-readable CSV, never its tables.
|
|
192
|
+
#
|
|
193
|
+
# The previous parser grepped '^Warning cnt' for a summary row. Two things
|
|
194
|
+
# made that never match: "Warning cnt" is a column header in the MIDDLE of
|
|
195
|
+
# a line ("Total nloc Avg.NLOC ... Warning cnt Fun Rt nloc Rt"), and
|
|
196
|
+
# lizard omits the summary block entirely when given explicit file
|
|
197
|
+
# arguments -- which is how this gate always invokes it. CC_WARNINGS was
|
|
198
|
+
# therefore always 0 and the flat-threshold path could not block anything.
|
|
199
|
+
#
|
|
200
|
+
# --csv has a stable column order (NLOC,CCN,token,PARAM,length,location,
|
|
201
|
+
# file,name,signature,...), so the verdict comes from the same numbers
|
|
202
|
+
# lizard uses, independent of version and table formatting. --csv here is
|
|
203
|
+
# the flat-threshold run; the ratchet branch above builds its own.
|
|
204
|
+
CC_CSV_FLAT=$("$LIZARD_PATH" --csv -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
|
|
205
|
+
CC_WARNINGS=$(printf '%s\n' "$CC_CSV_FLAT" | awk -F, -v t="$CCN_THRESHOLD" '
|
|
206
|
+
NF > 1 && $2 + 0 > t + 0 { c++ }
|
|
207
|
+
END { print c + 0 }
|
|
208
|
+
')
|
|
209
|
+
# A lizard that produced no parsable row must not read as "clean". Without
|
|
210
|
+
# this, a crashing or mis-invoked lizard yields 0 warnings and the gate
|
|
211
|
+
# reports PASSED on zero analysed functions -- the same silent-success
|
|
212
|
+
# failure this issue is about.
|
|
213
|
+
if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
|
|
214
|
+
END { exit(found ? 0 : 1) }'; then
|
|
215
|
+
echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
|
|
216
|
+
GATE_3_STATUS="WARN"
|
|
217
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
218
|
+
# shellcheck disable=SC2034
|
|
219
|
+
GATE_3_REASON="lizard_output_unparsable"
|
|
220
|
+
elif [ "$CC_WARNINGS" -gt 0 ]; then
|
|
188
221
|
echo "$CC_OUTPUT"
|
|
189
222
|
echo ""
|
|
190
223
|
echo "❌ BLOCKED - $CC_WARNINGS functions with CCN > $CCN_THRESHOLD found."
|
|
191
224
|
echo "Refactor high-complexity functions to keep below $CCN_THRESHOLD complexity."
|
|
225
|
+
GATE_3_STATUS="BLOCK"
|
|
226
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
227
|
+
# shellcheck disable=SC2034
|
|
228
|
+
GATE_3_REASON="ccn_over_threshold"
|
|
192
229
|
exit 1
|
|
193
230
|
else
|
|
194
231
|
echo "✅ PASSED - All functions within complexity threshold ($CCN_THRESHOLD)."
|
|
@@ -198,6 +235,8 @@ else
|
|
|
198
235
|
else
|
|
199
236
|
echo "⏭️ SKIPPED - Complexity (no source files to check)."
|
|
200
237
|
GATE_3_STATUS="SKIP"
|
|
238
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
239
|
+
# shellcheck disable=SC2034
|
|
201
240
|
GATE_3_REASON="no_source_files_changed"
|
|
202
241
|
fi
|
|
203
242
|
else
|
|
@@ -205,6 +244,8 @@ else
|
|
|
205
244
|
echo " Install with: pip install --user lizard"
|
|
206
245
|
echo " Gate 3: Complexity check (WARN, tool not available)"
|
|
207
246
|
GATE_3_STATUS="WARN"
|
|
247
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
248
|
+
# shellcheck disable=SC2034
|
|
208
249
|
GATE_3_REASON="tool_not_installed"
|
|
209
250
|
fi
|
|
210
251
|
fi
|
package/hooks/gate-4.sh
CHANGED
|
@@ -56,11 +56,15 @@ GATE_4_START=$(gate_start_ms)
|
|
|
56
56
|
GATE_4_STATUS=""
|
|
57
57
|
# #537: every non-run records WHY (no files / tool missing / unsupported lang)
|
|
58
58
|
# so the quality report can distinguish "not applicable" from "not covered".
|
|
59
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
60
|
+
# shellcheck disable=SC2034
|
|
59
61
|
GATE_4_REASON=""
|
|
60
62
|
|
|
61
63
|
if [ "$PROJECT_LANG" = "documentation-only" ]; then
|
|
62
64
|
echo "⏭️ SKIPPED - Principles check (documentation project)."
|
|
63
65
|
GATE_4_STATUS="SKIP"
|
|
66
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
67
|
+
# shellcheck disable=SC2034
|
|
64
68
|
GATE_4_REASON="documentation_project"
|
|
65
69
|
|
|
66
70
|
else
|
|
@@ -156,6 +160,8 @@ else
|
|
|
156
160
|
echo "⚠️ Warning: Principles checker execution failed"
|
|
157
161
|
echo "⏭️ SKIPPED - Principles check (execution issue)"
|
|
158
162
|
GATE_4_STATUS="SKIP"
|
|
163
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
164
|
+
# shellcheck disable=SC2034
|
|
159
165
|
GATE_4_REASON="checker_execution_error"
|
|
160
166
|
rm -f "$PRINCIPLES_JSON"
|
|
161
167
|
else
|
|
@@ -191,17 +197,23 @@ else
|
|
|
191
197
|
echo "ℹ️ npx/tsx not available - skipping principles check"
|
|
192
198
|
echo "⏭️ SKIPPED - Principles check (no Node.js/tsx)"
|
|
193
199
|
GATE_4_STATUS="SKIP"
|
|
200
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
201
|
+
# shellcheck disable=SC2034
|
|
194
202
|
GATE_4_REASON="tool_not_installed"
|
|
195
203
|
fi
|
|
196
204
|
else
|
|
197
205
|
echo "ℹ️ Principles checker not found in project - skipping"
|
|
198
206
|
echo "⏭️ SKIPPED - Principles check (checker not in project)"
|
|
199
207
|
GATE_4_STATUS="SKIP"
|
|
208
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
209
|
+
# shellcheck disable=SC2034
|
|
200
210
|
GATE_4_REASON="checker_not_found"
|
|
201
211
|
fi
|
|
202
212
|
else
|
|
203
213
|
echo "⏭️ SKIPPED - Principles check (no matching source files changed)"
|
|
204
214
|
GATE_4_STATUS="SKIP"
|
|
215
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
216
|
+
# shellcheck disable=SC2034
|
|
205
217
|
GATE_4_REASON="no_matching_files_changed"
|
|
206
218
|
fi
|
|
207
219
|
fi
|
package/hooks/gate-7.sh
CHANGED
|
@@ -12,11 +12,15 @@ GATE_7_START=$(gate_start_ms)
|
|
|
12
12
|
IAC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E "\.(tf|yaml|yml)$|Dockerfile" || true)
|
|
13
13
|
# #536: scanned_files makes a trivial PASS (nothing to scan) auditable in the
|
|
14
14
|
# quality report instead of reading like a covered security check.
|
|
15
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
16
|
+
# shellcheck disable=SC2034
|
|
15
17
|
GATE_7_SCANNED_FILES=0
|
|
16
18
|
if [ -z "$IAC_FILES" ]; then
|
|
17
19
|
echo "✅ PASSED - No IaC files detected in changes."
|
|
18
20
|
GATE_7_STATUS="PASS"
|
|
19
21
|
else
|
|
22
|
+
# Consumed by the sourcing parent, not here (see #548).
|
|
23
|
+
# shellcheck disable=SC2034
|
|
20
24
|
GATE_7_SCANNED_FILES=$(printf '%s\n' "$IAC_FILES" | sed -n '/./p' | wc -l | tr -d '[:space:]')
|
|
21
25
|
# Run IaC adapter. Resolution follows the documented 3-tier convention
|
|
22
26
|
# (adapter-common.sh): global ~/.config/xp-gate/adapters -> project
|
|
@@ -7,12 +7,22 @@
|
|
|
7
7
|
# is its shell projection. Editing it by hand makes the hook disagree with
|
|
8
8
|
# doctor.js, which reads the JS module directly — the exact divergence
|
|
9
9
|
# #495/#500 were about.
|
|
10
|
+
#
|
|
11
|
+
# Every HOOK_* constant below is read by the sourcing parent, not by this
|
|
12
|
+
# file. A linter checking this file alone cannot see that consumer, so each
|
|
13
|
+
# declaration carries a scoped directive with the reason stated at the site
|
|
14
|
+
# (#548 -- the same false SC2034 used to block consumer repos that vendor
|
|
15
|
+
# these hook modules). The directives are generated, not hand-added.
|
|
10
16
|
|
|
11
17
|
# Probe file used to pick the gate tier.
|
|
18
|
+
# Consumed by the sourcing parent, not here.
|
|
19
|
+
# shellcheck disable=SC2034
|
|
12
20
|
HOOK_GATE_PROBE="gate-3.sh"
|
|
13
21
|
|
|
14
22
|
# ADAPTER_DIR cascade, highest priority first.
|
|
15
23
|
# Each entry is "tier_id|base_var|probe_file".
|
|
24
|
+
# Consumed by the sourcing parent, not here.
|
|
25
|
+
# shellcheck disable=SC2034
|
|
16
26
|
HOOK_ADAPTER_DIR_TIERS=(
|
|
17
27
|
'global|GLOBAL_ADAPTER_DIR|adapter-common.sh'
|
|
18
28
|
'project|PROJECT_GITHOOKS|adapter-common.sh'
|
|
@@ -21,6 +31,8 @@ HOOK_ADAPTER_DIR_TIERS=(
|
|
|
21
31
|
|
|
22
32
|
# Adapter resolution tiers, highest priority first.
|
|
23
33
|
# Each entry is "tier_id|base_var|template" with {name} as the basename.
|
|
34
|
+
# Consumed by the sourcing parent, not here.
|
|
35
|
+
# shellcheck disable=SC2034
|
|
24
36
|
HOOK_ADAPTER_TIERS=(
|
|
25
37
|
'adapter_flat|ADAPTER_DIR|{name}.sh'
|
|
26
38
|
'adapter_nested|ADAPTER_DIR|adapters/{name}.sh'
|
|
@@ -32,6 +44,8 @@ HOOK_ADAPTER_TIERS=(
|
|
|
32
44
|
# Semantics differ from adapter tiers: each entry names the DIRECTORY that
|
|
33
45
|
# would hold the gate probe, so a match yields the directory (GATE_DIR), not
|
|
34
46
|
# the probe file itself. Entry format: "tier_id|base_var|(empty suffix)".
|
|
47
|
+
# Consumed by the sourcing parent, not here.
|
|
48
|
+
# shellcheck disable=SC2034
|
|
35
49
|
HOOK_GATE_TIERS=(
|
|
36
50
|
'gate_adapter_dir|ADAPTER_DIR|'
|
|
37
51
|
'gate_project|PROJECT_GITHOOKS|'
|
|
@@ -70,7 +70,7 @@ enforce_python_coverage() {
|
|
|
70
70
|
fi
|
|
71
71
|
|
|
72
72
|
if [ -n "$COVERAGE_PERCENT" ]; then
|
|
73
|
-
if [ "$COVERAGE_PERCENT" -lt 80 ]; then
|
|
73
|
+
if [ "$COVERAGE_PERCENT" -lt "${COVERAGE_THRESHOLD:-80}" ]; then
|
|
74
74
|
# #399: a subset run (smart test selection) measures only the tested
|
|
75
75
|
# files, so its global total is meaningless as a verdict — the full
|
|
76
76
|
# suite may well be >=80% even when the subset is not. Warn exactly as
|
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
|
|
2715
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2890
|
-
echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below
|
|
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
|
|
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
|
|
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
|
|
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":
|
|
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
|
},
|