@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/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
- # Run semgrep with JSON output
36
- # --config=p/security-audit: explicit security ruleset
37
- # --json: machine-readable output
38
- # --disable-version-check: skip network call
39
- SEMGREP_OUTPUT=$($SEMGREP_CMD scan --config=p/security-audit --json --disable-version-check $SEMGREP_FILES 2>&1)
40
- SEMGREP_EXIT=$?
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
- if [ "$SEMGREP_EXIT" -eq 0 ]; then
43
- echo " ✅ PASSED - No security vulnerabilities found."
44
- GATE_9_STATUS="PASS"
45
- elif [ "$SEMGREP_EXIT" -eq 1 ]; then
46
- # Findings detected - parse JSON to categorize
47
- CRITICAL_HIGH=$(echo "$SEMGREP_OUTPUT" | node -e "
48
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
49
- try{const data=JSON.parse(d);const results=data.results||[];
50
- const count=results.filter(r=>{const s=(r.extra&&r.extra.severity||'').toUpperCase();return s==='CRITICAL'||s==='HIGH';}).length;
51
- console.log(count);}catch(e){console.log('0');}
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
- MEDIUM_LOW=$(echo "$SEMGREP_OUTPUT" | node -e "
55
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
56
- try{const data=JSON.parse(d);const results=data.results||[];
57
- const count=results.filter(r=>{const s=(r.extra&&r.extra.severity||'').toUpperCase();return s==='MEDIUM'||s==='LOW';}).length;
58
- console.log(count);}catch(e){console.log('0');}
59
- });" 2>/dev/null || echo "0")
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
- # Extract top finding details
62
- FINDING_DETAILS=$(echo "$SEMGREP_OUTPUT" | node -e "
63
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
64
- try{const data=JSON.parse(d);const results=data.results||[];
65
- results.slice(0,5).forEach(r=>{const extra=r.extra||{};
66
- const severity=(extra.severity||'UNKNOWN').toUpperCase();
67
- const ruleId=r.check_id||'unknown';const path=r.path||'unknown';
68
- const line=(r.start&&r.start.line)||'?';const msg=(extra.message||'').slice(0,80);
69
- console.log(' ['+severity+'] '+ruleId);console.log(' '+path+':'+line+' → '+msg);console.log();});
70
- }catch(e){console.log(' (Failed to parse semgrep output)');}
71
- });" 2>/dev/null || echo " (Failed to parse semgrep output)")
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
- if [ "$CRITICAL_HIGH" -gt 0 ]; then
74
- echo ""
75
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
76
- echo " GATE 9: Semgrep Security Gate"
77
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
78
- echo " CRITICAL/HIGH: ${CRITICAL_HIGH} ❌ BLOCKED"
79
- echo " MEDIUM/LOW: ${MEDIUM_LOW} ⚠️ warning"
80
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
81
- echo " ❌ BLOCKED — Critical/High vulnerability found"
82
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
83
- echo "$FINDING_DETAILS"
84
- echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
85
- GATE_9_STATUS="FAIL"
86
- exit 1
87
- else
88
- echo ""
89
- echo " ✅ PASSED - No critical/high vulnerabilities"
90
- if [ "$MEDIUM_LOW" -gt 0 ]; then
91
- echo " ⚠️ ${MEDIUM_LOW} medium/low findings (warnings only)"
92
- echo "$FINDING_DETAILS"
93
- fi
94
- GATE_9_STATUS="PASS"
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
- # Known limitation (#527): Gradle multi-module, CMake subdirectories and
723
- # pnpm/Lerna workspaces are not disambiguated from single projects yet —
724
- # see the pinned BATS case.
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
- _skip_subdir_derive=1
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
- pytest --cov=. --cov-fail-under=80 --tb=short
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
- if command -v coverage &> /dev/null; then
2365
- COVERAGE_PERCENT=""
2366
- if [ -f ".coverage" ]; then
2367
- COVERAGE_PERCENT=$(coverage report --format=total 2>/dev/null | grep -o '[0-9]*%' | head -1 | tr -d '%') || true
2368
- fi
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
- GATE_11_END=$(date +%s)
3147
- GATE_11_DURATION=$((GATE_11_END - GATE_11_START))
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")