@boyingliu01/xp-gate 0.21.0 → 0.22.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/adapters/gate-9.sh +142 -60
- package/bin/xp-gate.js +2 -2
- package/gate-9.sh +142 -59
- package/hooks/gate-9.sh +142 -59
- package/hooks/lib/coverage-total.sh +72 -0
- package/hooks/pre-commit +88 -27
- package/hooks/pre-push +32 -0
- package/lib/__tests__/arch.test.js +403 -0
- package/lib/__tests__/doctor-469-hook-env.test.js +200 -0
- package/lib/arch.js +336 -49
- package/lib/doctor.js +319 -13
- package/lib/mattpocock-license-verdict.json +1 -1
- package/package.json +1 -1
- package/plugins/dsh/src/command.test.ts +14 -2
- package/plugins/dsh/src/command.ts +8 -1
- package/plugins/dsh/src/index.test.ts +43 -26
- package/plugins/dsh/src/index.ts +164 -138
- package/plugins/opencode/README.md +5 -2
- package/plugins/opencode/index.ts +215 -164
package/hooks/gate-9.sh
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
# Detects security vulnerabilities (SQL injection, XSS, etc.) in staged files
|
|
4
4
|
# Tool: semgrep -- https://semgrep.dev
|
|
5
5
|
# ============================================================================
|
|
6
|
+
# #532: this is a legacy gate-9 fallback copy, kept alive by
|
|
7
|
+
# resolveGateScript(9) in TS-less deployments (xp-gate gate sast without the
|
|
8
|
+
# src/gates/gate-9.ts entry). The verdict semantics below are ported from
|
|
9
|
+
# gate-10.sh (the #518 fix, v0.21.0) — the verdict block must stay byte
|
|
10
|
+
# identical across githooks/gate-9.sh, githooks/gates/gate-9-sast-security.sh
|
|
11
|
+
# and githooks/adapters/gate-9.sh; parity is asserted by
|
|
12
|
+
# githooks/__tests__/issue-532-gate9-verdict.test.bats (AC-A-02-04).
|
|
6
13
|
|
|
7
14
|
2>&1 echo ""
|
|
8
15
|
2>&1 echo "→ Gate 9: Semgrep SAST Security Scan..."
|
|
@@ -32,73 +39,149 @@ else
|
|
|
32
39
|
echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
|
|
33
40
|
GATE_9_STATUS="SKIP"
|
|
34
41
|
else
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
# --
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
# One path per line into an array. Quoting the newline-joined list would hand
|
|
43
|
+
# semgrep ONE argument containing newlines -> "Invalid scanning root" once
|
|
44
|
+
# >=2 files are staged (#490). Same defect class as the Gate 4 --files fix
|
|
45
|
+
# (#457): an array is the only form that keeps spaces intact AND splits files.
|
|
46
|
+
#
|
|
47
|
+
# Each element gets a ./ prefix: a tracked path may legitimately start with
|
|
48
|
+
# '-' (`git add -- ./-o.ts`), and bare in argv that element reads as an option
|
|
49
|
+
# and rewrites the scan it was meant to constrain. ./ is a path to every
|
|
50
|
+
# argument parser.
|
|
51
|
+
#
|
|
52
|
+
# The unquoted heredoc below expands $SEMGREP_FILES once, then feeds literal
|
|
53
|
+
# lines to `read`; bash never rescans the result for separators. Its correct
|
|
54
|
+
# reading rests on one property of the producer above, not of the loop:
|
|
55
|
+
# `git diff --cached --name-only` terminates paths with LF and quotes control
|
|
56
|
+
# characters (a CR in a name arrives as the four bytes \215, never as a bare
|
|
57
|
+
# CR), so one line is exactly one path. Asserted for spaces and a leading '-'
|
|
58
|
+
# by AC-490-04.
|
|
59
|
+
SEMGREP_ARGS=()
|
|
60
|
+
while IFS= read -r _semgrep_file; do
|
|
61
|
+
[ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
|
|
62
|
+
done <<EOF
|
|
63
|
+
$SEMGREP_FILES
|
|
64
|
+
EOF
|
|
41
65
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});" 2>/dev/null || echo "0")
|
|
66
|
+
# The verdict is parsed from the JSON with node (see below), so node is a
|
|
67
|
+
# hard dependency of this gate — check it BEFORE spending a semgrep run
|
|
68
|
+
# (#518 R2): without it we would silently SKIP every finding as
|
|
69
|
+
# "unparseable" and lose the whole security net on minimal CI images.
|
|
70
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
71
|
+
echo " ⚠️ WARN - node is not installed — Gate 9 needs it to parse semgrep's JSON verdict"
|
|
72
|
+
echo " Install node (https://nodejs.org); without it SAST findings cannot be classified"
|
|
73
|
+
echo " ⏭️ SKIPPED - SAST (node not installed)"
|
|
74
|
+
GATE_9_STATUS="SKIP"
|
|
75
|
+
else
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
# Run semgrep with JSON output. stdout and stderr are captured SEPARATELY:
|
|
78
|
+
# --json writes findings to stdout, progress/summary lines to stderr, and the
|
|
79
|
+
# old `2>&1` merge poisoned the very JSON the verdict now depends on (#518).
|
|
80
|
+
# SEMGREP_OUTPUT keeps its name and role — what the tool said, printed on the
|
|
81
|
+
# SKIP path (AC-475-04).
|
|
82
|
+
#
|
|
83
|
+
# #518: the exit code does NOT carry the verdict. semgrep exits 0 even with
|
|
84
|
+
# ERROR-severity findings, and its exit-code semantics have drifted across
|
|
85
|
+
# versions and platforms (upstream currently reports findings WITH --error
|
|
86
|
+
# as exit 1 and failures as 2, but that mapping is not something a gate
|
|
87
|
+
# should bet on). The JSON carries what the exit code never could —
|
|
88
|
+
# per-severity counts — so the verdict is read from the JSON and the exit
|
|
89
|
+
# code is demoted to "did the tool itself run". --error stays OFF: it would
|
|
90
|
+
# add nothing the JSON does not already provide, and keeping it off leaves
|
|
91
|
+
# failure exit codes unambiguous for the #475 diagnostics path.
|
|
92
|
+
SEMGREP_STDOUT_FILE=$(mktemp)
|
|
93
|
+
SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
|
|
94
|
+
SEMGREP_EXIT=$?
|
|
60
95
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})
|
|
96
|
+
# One node pass over the JSON: first line = verdict counts, remaining lines =
|
|
97
|
+
# finding details. The severity vocabulary is the UNION of the two scales
|
|
98
|
+
# (#518): semgrep's own ERROR is its most severe tier (it maps to the old
|
|
99
|
+
# CRITICAL/HIGH → BLOCK intent), its WARNING/INFO align with the old
|
|
100
|
+
# MEDIUM/LOW → warnings-only tier. Anything OUTSIDE both lists (missing,
|
|
101
|
+
# empty, or a future/new tier) counts as BLOCKING — a security gate must
|
|
102
|
+
# fail closed on severities it does not recognize (#518 R2). Parsing
|
|
103
|
+
# failure degrades to PARSE=FAIL — a fail-closed SKIP below, never a PASS.
|
|
104
|
+
SEMGREP_VERDICT=$(node -e '
|
|
105
|
+
let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{
|
|
106
|
+
let data;try{data=JSON.parse(d);}catch(e){console.log("PARSE=FAIL RESULTS=0 ERRORS=0 BLOCKING=0 WARNINGS=0");return;}
|
|
107
|
+
const results=data.results||[];const errors=data.errors||[];
|
|
108
|
+
const sev=r=>((r.extra&&r.extra.severity)||"").toUpperCase();
|
|
109
|
+
const WARNV=["WARNING","INFO","MEDIUM","LOW"];
|
|
110
|
+
let b=0,w=0;results.forEach(r=>{if(WARNV.indexOf(sev(r))>=0)w++;else b++;});
|
|
111
|
+
console.log("PARSE=OK RESULTS="+results.length+" ERRORS="+errors.length+" BLOCKING="+b+" WARNINGS="+w);
|
|
112
|
+
results.slice(0,5).forEach(r=>{const extra=r.extra||{};const s=sev(r)||"UNKNOWN";
|
|
113
|
+
console.log(" ["+s+"] "+(r.check_id||"unknown"));
|
|
114
|
+
console.log(" "+(r.path||"unknown")+":"+((r.start&&r.start.line)||"?")+" → "+(extra.message||"").slice(0,80));
|
|
115
|
+
console.log("");});
|
|
116
|
+
});' < "$SEMGREP_STDOUT_FILE") || SEMGREP_VERDICT="PARSE=FAIL RESULTS=0 ERRORS=0 BLOCKING=0 WARNINGS=0"
|
|
72
117
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
PARSE_LINE=$(printf '%s\n' "$SEMGREP_VERDICT" | sed -n '1p')
|
|
119
|
+
FINDING_DETAILS=$(printf '%s\n' "$SEMGREP_VERDICT" | sed -n '2,$p')
|
|
120
|
+
V_PARSE=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/^PARSE=\([A-Z]*\) .*/\1/p')
|
|
121
|
+
V_RESULTS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* RESULTS=\([0-9]*\) .*/\1/p')
|
|
122
|
+
V_ERRORS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* ERRORS=\([0-9]*\) .*/\1/p')
|
|
123
|
+
V_BLOCKING=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* BLOCKING=\([0-9]*\) .*/\1/p')
|
|
124
|
+
V_WARNINGS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* WARNINGS=\([0-9]*\)$/\1/p')
|
|
125
|
+
# Keep the raw stdout tail for the unparseable branch's diagnostics BEFORE
|
|
126
|
+
# the temp file goes away (#518 R2: stderr alone often says nothing there).
|
|
127
|
+
SEMGREP_STDOUT_TAIL=$(tail -5 "$SEMGREP_STDOUT_FILE" 2>/dev/null)
|
|
128
|
+
rm -f "$SEMGREP_STDOUT_FILE"
|
|
129
|
+
|
|
130
|
+
# Fail closed: an unparseable payload OR an OK parse with any missing count
|
|
131
|
+
# field (verdict-line format drift) must SKIP, never silently degrade to
|
|
132
|
+
# PASS (#518).
|
|
133
|
+
if [ "$V_PARSE" != "OK" ] || [ -z "$V_RESULTS" ] || [ -z "$V_ERRORS" ] || [ -z "$V_BLOCKING" ] || [ -z "$V_WARNINGS" ]; then
|
|
134
|
+
# Unparseable output: nothing the exit code claims can be trusted (#518).
|
|
135
|
+
# Fail closed — SKIP with diagnostics, never a PASS.
|
|
136
|
+
echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
|
|
137
|
+
if [ -n "$SEMGREP_OUTPUT" ]; then
|
|
138
|
+
echo " ── what semgrep reported on stderr ──"
|
|
139
|
+
printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
|
|
140
|
+
fi
|
|
141
|
+
if [ -n "$SEMGREP_STDOUT_TAIL" ]; then
|
|
142
|
+
echo " ── what semgrep wrote to stdout (tail) ──"
|
|
143
|
+
printf '%s\n' "$SEMGREP_STDOUT_TAIL" | sed 's/^/ /'
|
|
144
|
+
fi
|
|
145
|
+
echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
|
|
146
|
+
GATE_9_STATUS="SKIP"
|
|
147
|
+
elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
|
|
148
|
+
# Nothing was scanned to a verdict: semgrep either failed outright
|
|
149
|
+
# (nonzero exit) or completed but reported scan errors for every input
|
|
150
|
+
# (#518 R2: exit 0 + results:[] + errors:[{...}] used to fall through to
|
|
151
|
+
# PASS). An operational failure must neither block nor claim a pass
|
|
152
|
+
# (#475 kept).
|
|
153
|
+
echo " ⚠️ semgrep exit=${SEMGREP_EXIT}, scan errors=${V_ERRORS}, findings=${V_RESULTS} — nothing to judge"
|
|
154
|
+
if [ -n "$SEMGREP_OUTPUT" ]; then
|
|
155
|
+
echo " ── what semgrep reported ──"
|
|
156
|
+
printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
|
|
95
157
|
fi
|
|
96
|
-
else
|
|
97
|
-
# semgrep runtime error (timeout, config error, etc.)
|
|
98
|
-
echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
|
|
99
158
|
echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
|
|
100
159
|
GATE_9_STATUS="SKIP"
|
|
160
|
+
elif [ "$V_BLOCKING" -gt 0 ]; then
|
|
161
|
+
echo ""
|
|
162
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
163
|
+
echo " GATE 9: Semgrep Security Gate"
|
|
164
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
165
|
+
echo " CRITICAL/HIGH/ERROR: ${V_BLOCKING} ❌ BLOCKED"
|
|
166
|
+
echo " MEDIUM/LOW/WARNING/INFO: ${V_WARNINGS} ⚠️ warning"
|
|
167
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
168
|
+
echo " ❌ BLOCKED — blocking-severity vulnerability found"
|
|
169
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
170
|
+
echo "$FINDING_DETAILS"
|
|
171
|
+
echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
|
|
172
|
+
GATE_9_STATUS="FAIL"
|
|
173
|
+
exit 1
|
|
174
|
+
else
|
|
175
|
+
echo ""
|
|
176
|
+
echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
|
|
177
|
+
if [ "$V_WARNINGS" -gt 0 ]; then
|
|
178
|
+
echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
|
|
179
|
+
echo "$FINDING_DETAILS"
|
|
180
|
+
fi
|
|
181
|
+
GATE_9_STATUS="PASS"
|
|
101
182
|
fi
|
|
183
|
+
|
|
184
|
+
fi # node available
|
|
102
185
|
fi
|
|
103
186
|
fi
|
|
104
187
|
record_gate_audit "gate-9" "sast-security" "$GATE_9_STATUS" "0" "$GATE_9_START"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Gate 5's Python coverage parsing and threshold verdict (#523).
|
|
3
|
+
#
|
|
4
|
+
# Sourced by githooks/pre-commit; kept in its own file so the contract is
|
|
5
|
+
# unit-testable without executing the hook
|
|
6
|
+
# (githooks/__tests__/issue-523-python-coverage-parse.test.bats).
|
|
7
|
+
#
|
|
8
|
+
# The defect this file exists for: `coverage report --format=total` prints a
|
|
9
|
+
# BARE number ("97"), but the hook piped it through `grep -o '[0-9]*%'` — a
|
|
10
|
+
# pattern that requires a literal percent sign — so COVERAGE_PERCENT was always
|
|
11
|
+
# empty and the 80% threshold branch never executed. The gate answered "Could
|
|
12
|
+
# not determine" forever: a silent no-op, not a verdict.
|
|
13
|
+
|
|
14
|
+
# Parse the raw output of `coverage report --format=total` (stdin) and echo the
|
|
15
|
+
# integer total percentage — or nothing when the input carries no number.
|
|
16
|
+
#
|
|
17
|
+
# coverage.py 7.x --format=total prints a bare number; other tools (and older
|
|
18
|
+
# forms) append a percent sign. Only a line that IS a bare number (optionally
|
|
19
|
+
# %-suffixed) is accepted — a line that merely CONTAINS a number ("WARNING: 2
|
|
20
|
+
# files skipped") must not be mistaken for the total, so the whole line is
|
|
21
|
+
# matched, not the first token. A decimal part is truncated (floor — the
|
|
22
|
+
# conservative direction for a threshold gate, the same convention as the Java
|
|
23
|
+
# path's ${VAR%.*}). Inputs without any bare-number line (empty, "No data to
|
|
24
|
+
# report.", warnings) produce NO output so the caller takes its
|
|
25
|
+
# cannot-determine path: a wrong number is worse than none.
|
|
26
|
+
parse_coverage_total() {
|
|
27
|
+
awk '
|
|
28
|
+
{
|
|
29
|
+
line = $0
|
|
30
|
+
sub(/%$/, "", line)
|
|
31
|
+
gsub(/^[ \t]+|[ \t]+$/, "", line)
|
|
32
|
+
if (line ~ /^[0-9]+(\.[0-9]+)?$/) {
|
|
33
|
+
sub(/\.[0-9]+$/, "", line)
|
|
34
|
+
print line
|
|
35
|
+
exit
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Gate 5 Stage 2 Python coverage enforcement — the hook's python case body,
|
|
42
|
+
# extracted so the mock-PATH test exercises the real control flow. Echoes the
|
|
43
|
+
# verdict lines and returns 1 on BLOCK (the caller turns that into `exit 1`).
|
|
44
|
+
#
|
|
45
|
+
# COVERAGE_PERCENT is deliberately NOT declared local: the hook's quality
|
|
46
|
+
# report reads it after the Stage-2 loop (last writer wins across languages),
|
|
47
|
+
# exactly as the inline branch did.
|
|
48
|
+
enforce_python_coverage() {
|
|
49
|
+
if ! command -v coverage &> /dev/null; then
|
|
50
|
+
echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
COVERAGE_PERCENT=""
|
|
55
|
+
if [ -f ".coverage" ]; then
|
|
56
|
+
COVERAGE_PERCENT=$(coverage report --format=total 2>/dev/null | parse_coverage_total) || true
|
|
57
|
+
fi
|
|
58
|
+
if [ -z "$COVERAGE_PERCENT" ] && [ -f "coverage/.coverage" ]; then
|
|
59
|
+
COVERAGE_PERCENT=$(coverage report --format=total -i coverage 2>/dev/null | parse_coverage_total) || true
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if [ -n "$COVERAGE_PERCENT" ]; then
|
|
63
|
+
if [ "$COVERAGE_PERCENT" -lt 80 ]; then
|
|
64
|
+
echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
|
|
65
|
+
return 1
|
|
66
|
+
fi
|
|
67
|
+
echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
|
|
68
|
+
else
|
|
69
|
+
echo "⚠️ Could not determine Python coverage percentage. Ensure pytest-cov or coverage.py data exists."
|
|
70
|
+
fi
|
|
71
|
+
return 0
|
|
72
|
+
}
|
package/hooks/pre-commit
CHANGED
|
@@ -224,6 +224,12 @@ source "${AUDIT_SCRIPT_DIR}/lib/jscpd-run.sh"
|
|
|
224
224
|
# Issue #476: Gate 11's sprint-gate.sh resolution chain and decision translation.
|
|
225
225
|
source "${AUDIT_SCRIPT_DIR}/lib/sprint-gate-report.sh"
|
|
226
226
|
|
|
227
|
+
# Issue #523: Gate 5's Python coverage parsing and threshold verdict. The old
|
|
228
|
+
# `grep -o '[0-9]*%'` never matched --format=total's bare number, so the 80%
|
|
229
|
+
# branch never ran; sourced as a library so the fail-closed contract is
|
|
230
|
+
# unit-testable without executing this hook.
|
|
231
|
+
source "${AUDIT_SCRIPT_DIR}/lib/coverage-total.sh"
|
|
232
|
+
|
|
227
233
|
# Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
|
|
228
234
|
_MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
|
|
229
235
|
|
|
@@ -710,6 +716,39 @@ if [ -z "$PROJECT_LANGS" ] && [ -z "$_CONFIG_LANGS" ]; then
|
|
|
710
716
|
done
|
|
711
717
|
return 1
|
|
712
718
|
}
|
|
719
|
+
# #527: two ecosystems whose MULTI-module layouts normally carry a root
|
|
720
|
+
# marker, so a root marker alone cannot prove "single project":
|
|
721
|
+
# - Gradle: root build.gradle(.kts) + settings.gradle(.kts) with include
|
|
722
|
+
# - CMake: root CMakeLists.txt + a nested */CMakeLists.txt
|
|
723
|
+
# Both probes are bounded (two file reads for Gradle; one maxdepth-7 find
|
|
724
|
+
# for CMake — MC-05, no new unbounded scan) and only run once a root marker
|
|
725
|
+
# is present. A false positive keeps derivation ON — the #515 default —
|
|
726
|
+
# so the failure direction is never a false BLOCK.
|
|
727
|
+
_gradle_multi_module_root() {
|
|
728
|
+
local _sg
|
|
729
|
+
for _sg in settings.gradle settings.gradle.kts; do
|
|
730
|
+
# include ':app' (Groovy) / include(":app") (Kotlin DSL). The char
|
|
731
|
+
# class anchors on the token after `include`, so `includeBuild` and
|
|
732
|
+
# comment prose cannot false-positive.
|
|
733
|
+
if [ -f "$_sg" ] && grep -qE "include[[:space:]]*['\"(]" "$_sg" 2>/dev/null; then
|
|
734
|
+
return 0
|
|
735
|
+
fi
|
|
736
|
+
done
|
|
737
|
+
return 1
|
|
738
|
+
}
|
|
739
|
+
_cmake_multi_module_root() {
|
|
740
|
+
[ -f "CMakeLists.txt" ] || return 1
|
|
741
|
+
# A nested CMakeLists.txt (depth 2..7 below the root) means the root file
|
|
742
|
+
# is a wiring file, not the whole project.
|
|
743
|
+
# -prune, not -not -path: filters the output AND stops traversal, so
|
|
744
|
+
# .git/node_modules are never descended into — the old form walked the
|
|
745
|
+
# whole tree on every commit of a repo that has both a root
|
|
746
|
+
# CMakeLists.txt and a big node_modules (#532 walkthrough FC-04).
|
|
747
|
+
# -mindepth is deliberately absent (a global -mindepth suppresses the
|
|
748
|
+
# depth-1 prune action); the root file is excluded by the awk NF>2
|
|
749
|
+
# filter instead (./CMakeLists.txt has no directory component).
|
|
750
|
+
[ -n "$(find . -maxdepth 7 \( -path '*/.git' -o -path '*/node_modules' \) -prune -o -name "CMakeLists.txt" -print 2>/dev/null | awk -F/ 'NF>2' | head -1)" ]
|
|
751
|
+
}
|
|
713
752
|
# #515: when the repo ROOT itself declares the project, a nested marker that
|
|
714
753
|
# is merely a module file (skill_cert/cli/setup.py incident) must not hijack
|
|
715
754
|
# PROJECT_SUBDIR — Gates 1-9 would run from the wrong cwd and false-BLOCK on
|
|
@@ -719,9 +758,12 @@ if [ -z "$PROJECT_LANGS" ] && [ -z "$_CONFIG_LANGS" ]; then
|
|
|
719
758
|
# check runs FIRST (#515 R2 delphi walkthrough): a root package.json with a
|
|
720
759
|
# "workspaces" key declares a JS monorepo (often together with a shared
|
|
721
760
|
# tsconfig.json), not a single project, so derivation keeps running there.
|
|
722
|
-
#
|
|
723
|
-
#
|
|
724
|
-
#
|
|
761
|
+
# #527 (resolved): Gradle multi-module and CMake subdirectory layouts are
|
|
762
|
+
# disambiguated by the predicates above; the short-circuit now applies to
|
|
763
|
+
# genuinely single-project roots. pnpm/Lerna workspaces remain a known
|
|
764
|
+
# limitation (no probe yet). Migration: repos that depended on the old
|
|
765
|
+
# short-circuit set XP_GATE_ROOT_SHORTCIRCUIT=1 (or delete the root
|
|
766
|
+
# marker); see githooks/__tests__/issue-527-subdir-multimodule.test.bats.
|
|
725
767
|
_skip_subdir_derive=0
|
|
726
768
|
if [ -f package.json ] && grep -q '"workspaces":' package.json 2>/dev/null; then
|
|
727
769
|
_skip_subdir_derive=0 # JS monorepo root: subproject derivation stays on
|
|
@@ -729,7 +771,16 @@ if [ -z "$PROJECT_LANGS" ] && [ -z "$_CONFIG_LANGS" ]; then
|
|
|
729
771
|
[ -f setup.py ] || [ -f go.mod ] || [ -f pubspec.yaml ] || \
|
|
730
772
|
[ -f pom.xml ] || [ -f build.gradle ] || \
|
|
731
773
|
[ -f build.gradle.kts ] || [ -f CMakeLists.txt ]; then
|
|
732
|
-
|
|
774
|
+
# #527: before short-circuiting, ask whether the root marker declares
|
|
775
|
+
# MODULES (Gradle settings include / nested CMakeLists) rather than one
|
|
776
|
+
# project. The override restores the pre-#527 behavior explicitly.
|
|
777
|
+
if [ "${XP_GATE_ROOT_SHORTCIRCUIT:-0}" = "1" ]; then
|
|
778
|
+
_skip_subdir_derive=1
|
|
779
|
+
elif _gradle_multi_module_root || _cmake_multi_module_root; then
|
|
780
|
+
_skip_subdir_derive=0 # root marker declares modules — keep deriving
|
|
781
|
+
else
|
|
782
|
+
_skip_subdir_derive=1
|
|
783
|
+
fi
|
|
733
784
|
fi
|
|
734
785
|
_subdir_set=""
|
|
735
786
|
if [ "$_skip_subdir_derive" -eq 0 ]; then
|
|
@@ -2267,7 +2318,23 @@ else
|
|
|
2267
2318
|
;;
|
|
2268
2319
|
"python")
|
|
2269
2320
|
if command -v pytest &> /dev/null && command -v coverage &> /dev/null; then
|
|
2270
|
-
|
|
2321
|
+
# Issue #45 (merged in #514, replacing a machine-side hotfix that
|
|
2322
|
+
# kept drifting out of the repo — the exact class #514 exists to
|
|
2323
|
+
# kill). BOTH the scope and the threshold must come from the
|
|
2324
|
+
# target repo's pyproject.toml ([tool.coverage.run] source,
|
|
2325
|
+
# [tool.coverage.report] fail_under — e.g. source=["src"],
|
|
2326
|
+
# fail_under=82): a hardcoded `--cov=. --cov-fail-under=80` counts
|
|
2327
|
+
# tests/ into the denominator and produced a measured 76.49%
|
|
2328
|
+
# false-red BLOCKED. tomllib first; a repo declaring neither gets
|
|
2329
|
+
# the historical `--cov=. --cov-fail-under=80` behavior verbatim.
|
|
2330
|
+
PY_COV_SOURCE=$(python -c "import tomllib;print(' '.join('--cov='+s for s in tomllib.load(open('pyproject.toml','rb'))['tool']['coverage']['run'].get('source',[])))" 2>/dev/null || echo "")
|
|
2331
|
+
PY_COV_FAIL_UNDER=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['tool']['coverage']['report']['fail_under'])" 2>/dev/null || echo "")
|
|
2332
|
+
if [ -z "$PY_COV_FAIL_UNDER" ]; then
|
|
2333
|
+
PY_COV_FAIL_UNDER=$(grep -oE 'fail_under *= *[0-9]+' pyproject.toml 2>/dev/null | grep -oE '[0-9]+' | head -1)
|
|
2334
|
+
fi
|
|
2335
|
+
[ -z "$PY_COV_SOURCE" ] && PY_COV_SOURCE="--cov=."
|
|
2336
|
+
[ -z "$PY_COV_FAIL_UNDER" ] && PY_COV_FAIL_UNDER=80
|
|
2337
|
+
pytest $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
|
|
2271
2338
|
COV_EXIT=$?
|
|
2272
2339
|
else
|
|
2273
2340
|
echo "pytest/coverage not available, running coverage via adapter..."
|
|
@@ -2361,26 +2428,11 @@ else
|
|
|
2361
2428
|
;;
|
|
2362
2429
|
"python")
|
|
2363
2430
|
COVERAGE_ENFORCED=true
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
if [ -z "$COVERAGE_PERCENT" ] && [ -f "coverage/.coverage" ]; then
|
|
2370
|
-
COVERAGE_PERCENT=$(coverage report --format=total -i coverage 2>/dev/null | grep -o '[0-9]*%' | head -1 | tr -d '%') || true
|
|
2371
|
-
fi
|
|
2372
|
-
if [ -n "$COVERAGE_PERCENT" ]; then
|
|
2373
|
-
if [ "$COVERAGE_PERCENT" -lt 80 ]; then
|
|
2374
|
-
echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
|
|
2375
|
-
exit 1
|
|
2376
|
-
fi
|
|
2377
|
-
echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
|
|
2378
|
-
else
|
|
2379
|
-
echo "⚠️ Could not determine Python coverage percentage. Ensure pytest-cov or coverage.py data exists."
|
|
2380
|
-
fi
|
|
2381
|
-
else
|
|
2382
|
-
echo "⚠️ coverage.py not available, cannot enforce Python coverage threshold."
|
|
2383
|
-
fi
|
|
2431
|
+
# Issue #523: parsing + threshold verdict live in lib/coverage-total.sh
|
|
2432
|
+
# (the old `grep -o '[0-9]*%'` never matched --format=total's bare
|
|
2433
|
+
# number, so this branch never reached the threshold). Returns 1 on
|
|
2434
|
+
# BLOCK after echoing the verdict.
|
|
2435
|
+
enforce_python_coverage || exit 1
|
|
2384
2436
|
;;
|
|
2385
2437
|
"java")
|
|
2386
2438
|
COVERAGE_ENFORCED=true
|
|
@@ -3143,8 +3195,17 @@ else
|
|
|
3143
3195
|
GATE_11_STATUS="SKIP"
|
|
3144
3196
|
fi
|
|
3145
3197
|
|
|
3146
|
-
|
|
3147
|
-
|
|
3198
|
+
# Issue #524: pair the units. gate_start_ms is epoch MILLISECONDS (#370 helper);
|
|
3199
|
+
# the old `date +%s` end was epoch SECONDS, so the subtraction printed about
|
|
3200
|
+
# -1.79e12 s and poisoned .xp-gate/reports/pre-commit/*.json. now_ms is the same
|
|
3201
|
+
# helper gate_start_ms delegates to; display divides by 1000.
|
|
3202
|
+
GATE_11_END=$(now_ms)
|
|
3203
|
+
GATE_11_DURATION_MS=$((GATE_11_END - GATE_11_START))
|
|
3204
|
+
if [ "$GATE_11_DURATION_MS" -lt 0 ] 2>/dev/null; then
|
|
3205
|
+
# Clock skew or a poisoned timestamp must not print a negative runtime.
|
|
3206
|
+
GATE_11_DURATION_MS=0
|
|
3207
|
+
fi
|
|
3208
|
+
GATE_11_DURATION=$((GATE_11_DURATION_MS / 1000))
|
|
3148
3209
|
echo " Gate 11 ($GATE_11_STATUS) completed in ${GATE_11_DURATION}s"
|
|
3149
3210
|
record_gate_audit "gate-11" "sprint-flow" "$GATE_11_STATUS" "0" "$GATE_11_START"
|
|
3150
3211
|
echo ""
|
package/hooks/pre-push
CHANGED
|
@@ -49,6 +49,7 @@ report = {
|
|
|
49
49
|
'verdict': '$verdict',
|
|
50
50
|
},
|
|
51
51
|
'gates': [
|
|
52
|
+
{'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {}},
|
|
52
53
|
{'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
|
|
53
54
|
{'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {}},
|
|
54
55
|
{'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PYTHON_STATUS:-SKIP}', 'details': {}},
|
|
@@ -167,6 +168,37 @@ if [ "$DOC_ONLY" = "true" ]; then
|
|
|
167
168
|
exit 0
|
|
168
169
|
fi
|
|
169
170
|
|
|
171
|
+
# ============================================================================
|
|
172
|
+
# GATE MP: GATE-SCRIPT MIRROR PARITY (#532 / REQ-A-02 / AC-A-02-04)
|
|
173
|
+
# ============================================================================
|
|
174
|
+
# The npm package ships byte mirrors of every gate script (package root,
|
|
175
|
+
# hooks/, adapters/). A canonical edit without `sync-package-content.js --fix`
|
|
176
|
+
# ships stale copies silently — the #532 SAST verdict fix almost shipped that
|
|
177
|
+
# way (the fix landed on gate-10.sh while three fallback copies kept the
|
|
178
|
+
# #518 defects). Push is the moment code leaves the machine, so the parity
|
|
179
|
+
# guard runs here (fail-closed) and in CI's mirror-parity job. Read-only:
|
|
180
|
+
# compares bytes and git index modes; heals nothing.
|
|
181
|
+
GATE_MP_STATUS="SKIP"
|
|
182
|
+
MP_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
183
|
+
if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
|
|
184
|
+
if (cd "$MP_ROOT" && bash scripts/check-hook-mirror.sh); then
|
|
185
|
+
GATE_MP_STATUS="PASS"
|
|
186
|
+
echo "✅ Gate MP: gate-script mirror parity OK"
|
|
187
|
+
else
|
|
188
|
+
echo ""
|
|
189
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
190
|
+
echo " ❌ GATE MP FAILED — PUSH BLOCKED"
|
|
191
|
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
192
|
+
echo "A gate script changed without its npm-package mirrors being synced."
|
|
193
|
+
echo "Fix locally: node src/npm-package/scripts/sync-package-content.js --fix"
|
|
194
|
+
echo "Then commit the mirrors and push again."
|
|
195
|
+
exit 1
|
|
196
|
+
fi
|
|
197
|
+
else
|
|
198
|
+
echo "⚠️ Gate MP: check-hook-mirror.sh not found. SKIP — Gate MP."
|
|
199
|
+
fi
|
|
200
|
+
echo ""
|
|
201
|
+
|
|
170
202
|
# Size limits check — REMOVED: AI workflows generate large cumulative pushes; size is not a quality signal
|
|
171
203
|
DIFF_STATS=$(git diff origin/main...HEAD --stat 2>/dev/null || git diff origin/master...HEAD --stat 2>/dev/null)
|
|
172
204
|
FILES_CHANGED=$(echo "$DIFF_STATS" | tail -1 | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' || echo "0")
|