@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.
Files changed (52) hide show
  1. package/adapters/iac.sh +45 -4
  2. package/adapters/java.sh +82 -16
  3. package/adapters/powershell.sh +70 -9
  4. package/bin/xp-gate.js +8 -0
  5. package/gate-10.sh +32 -2
  6. package/gate-3.sh +47 -6
  7. package/gate-4.sh +12 -0
  8. package/gate-7.sh +4 -0
  9. package/hooks/gate-10.sh +32 -2
  10. package/hooks/gate-3.sh +47 -6
  11. package/hooks/gate-4.sh +12 -0
  12. package/hooks/gate-7.sh +4 -0
  13. package/hooks/hook-module-resolution.sh +14 -0
  14. package/hooks/lib/coverage-total.sh +1 -1
  15. package/hooks/pre-commit +138 -17
  16. package/hooks/pre-push +97 -6
  17. package/hooks/sprint-gate.sh +81 -2
  18. package/lib/__tests__/crap-cli.test.js +76 -0
  19. package/lib/__tests__/crap.test.js +137 -0
  20. package/lib/__tests__/hook-health.test.js +85 -0
  21. package/lib/__tests__/retro-gate-m.test.js +144 -0
  22. package/lib/coverage-total.sh +92 -0
  23. package/lib/crap-cli.js +191 -0
  24. package/lib/crap.js +196 -0
  25. package/lib/doctor.js +44 -0
  26. package/lib/hook-health.js +121 -0
  27. package/lib/mattpocock-license-verdict.json +1 -1
  28. package/lib/retro.js +139 -2
  29. package/mock-policy/AGENTS.md +2 -2
  30. package/mutation/AGENTS.md +2 -2
  31. package/package.json +1 -1
  32. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  33. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/dsh/package.json +1 -1
  37. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/opencode/package.json +1 -1
  41. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/plugins/qoder/plugin.json +1 -1
  45. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  46. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  47. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  48. package/principles/AGENTS.md +2 -2
  49. package/skills/delphi-review/AGENTS.md +2 -2
  50. package/skills/sprint-flow/AGENTS.md +2 -2
  51. package/skills/test-specification-alignment/AGENTS.md +2 -2
  52. 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
- 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)$' || true)
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
- # Parse warning count from the summary table: "Warning cnt 8"
183
- # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
184
- CC_WARNINGS=$(echo "$CC_OUTPUT" | grep "^Warning cnt" | awk '{print $NF}' | tr -d '[:space:]' | sed 's/[^0-9]//g' || true)
185
- CC_WARNINGS=${CC_WARNINGS:-0}
186
-
187
- if [ "$CC_WARNINGS" -gt 0 ]; then
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 drop the
2715
- # global threshold — subset coverage is meaningless globally.
2817
+ # Subset run: keep --cov so data is collected, but the global
2818
+ # threshold must be actively DISABLED, not merely omitted.
2819
+ # Omitting --cov-fail-under does not work (#556): pytest-cov
2820
+ # reads fail_under from pyproject on its own, so the floor
2821
+ # stayed in force, a selective run at 23% exited 1, and the
2822
+ # exit was escalated to a hard BLOCK -- printing "Partial
2823
+ # coverage ... not full suite" immediately above "BLOCKED -
2824
+ # Coverage check FAILED". --cov-fail-under=0 is the documented
2825
+ # way to switch it off for one invocation.
2716
2826
  PARTIAL_TEST_RUN=true
2717
2827
  # shellcheck disable=SC2086
2718
- pytest $PY_XDIST_ARGS $PY_COV_SOURCE --tb=short $PY_TARGETS
2828
+ pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under=0 --tb=short $PY_TARGETS
2719
2829
  COV_EXIT=$((COV_EXIT || $?))
2720
2830
  fi
2721
2831
  fi
@@ -2731,7 +2841,7 @@ else
2731
2841
  go test -coverprofile=coverage.out ./... 2>/dev/null
2732
2842
  TOTAL_COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | grep "^total:" | awk '{print substr($3, 1, length($3)-1)}')
2733
2843
  if [ -n "$TOTAL_COVERAGE" ] && (( $(echo "$TOTAL_COVERAGE < 80" | bc -l 2>/dev/null || echo "0") )); then
2734
- echo "❌ BLOCKED - Go coverage $TOTAL_COVERAGE% below 80% threshold"
2844
+ echo "❌ BLOCKED - Go coverage $TOTAL_COVERAGE% below ${COVERAGE_THRESHOLD}% threshold"
2735
2845
  exit 1
2736
2846
  fi
2737
2847
  echo "Go coverage: $TOTAL_COVERAGE%"
@@ -2789,14 +2899,14 @@ else
2789
2899
  } catch(e) { console.log('parse_error'); }
2790
2900
  " 2>/dev/null)
2791
2901
  if [ "$COVERAGE_PERCENT" != "parse_error" ] && [ -n "$COVERAGE_PERCENT" ]; then
2792
- if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2902
+ if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
2793
2903
  # Only a subset test run (smart selection) leaves partial coverage data;
2794
2904
  # a full suite run must block on <80% even when many test files changed.
2795
2905
  if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2796
2906
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2797
2907
  echo " Run 'npx vitest run --coverage' for full coverage check."
2798
2908
  else
2799
- echo "❌ BLOCKED - TypeScript coverage ${COVERAGE_PERCENT}% below 80% threshold"
2909
+ echo "❌ BLOCKED - TypeScript coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2800
2910
  exit 1
2801
2911
  fi
2802
2912
  else
@@ -2860,7 +2970,7 @@ else
2860
2970
  echo "⚠️ Partial coverage ${COVERAGE_ROUNDED}% (subset test run, not full suite)."
2861
2971
  echo " Run the full test suite for a complete Java coverage check."
2862
2972
  else
2863
- echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below 80% threshold"
2973
+ echo "❌ BLOCKED - Java coverage ${COVERAGE_ROUNDED}% below ${COVERAGE_THRESHOLD}% threshold"
2864
2974
  exit 1
2865
2975
  fi
2866
2976
  else
@@ -2886,8 +2996,8 @@ else
2886
2996
  if [ -f "coverage/lcov.info" ]; then
2887
2997
  COVERAGE_PERCENT=$(parse_lcov_coverage "coverage/lcov.info" 2>/dev/null) || COVERAGE_PERCENT="0"
2888
2998
  if [ "$COVERAGE_PERCENT" != "0" ] && [ -n "$COVERAGE_PERCENT" ]; then
2889
- if [ "$COVERAGE_PERCENT" -lt 80 ]; then
2890
- echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below 80% threshold"
2999
+ if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
3000
+ echo "❌ BLOCKED - Flutter/Dart coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2891
3001
  exit 1
2892
3002
  fi
2893
3003
  echo "Flutter/Dart coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
@@ -2926,7 +3036,7 @@ else
2926
3036
  COVERAGE_STATUS=${COVERAGE_RESULT%%|*}
2927
3037
  COVERAGE_PERCENT=${COVERAGE_RESULT#*|}
2928
3038
  if [ "$COVERAGE_STATUS" = "below" ]; then
2929
- echo "❌ BLOCKED - PowerShell coverage ${COVERAGE_PERCENT}% below 80% threshold"
3039
+ echo "❌ BLOCKED - PowerShell coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2930
3040
  exit 1
2931
3041
  fi
2932
3042
  echo "PowerShell coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
@@ -2963,14 +3073,14 @@ else
2963
3073
  fi
2964
3074
  fi
2965
3075
  if [ -n "$COVERAGE_PERCENT" ] && [ "$COVERAGE_PERCENT" != "parse_error" ] && [ "$COVERAGE_PERCENT" -gt 0 ]; then
2966
- if [ "$COVERAGE_PERCENT" -lt 80 ]; then
3076
+ if [ "$COVERAGE_PERCENT" -lt "$COVERAGE_THRESHOLD" ]; then
2967
3077
  # Only a subset test run (smart selection) warns — the full suite may
2968
3078
  # still reach ≥80%; a full suite run must block.
2969
3079
  if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
2970
3080
  echo "⚠️ Partial coverage ${COVERAGE_PERCENT}% (subset test run, not full suite)."
2971
3081
  echo " Run the full test suite for a complete ${CURRENT_LANG} coverage check."
2972
3082
  else
2973
- echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below 80% threshold"
3083
+ echo "❌ BLOCKED - ${CURRENT_LANG} coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD}% threshold"
2974
3084
  exit 1
2975
3085
  fi
2976
3086
  else
@@ -3019,10 +3129,15 @@ else
3019
3129
  }
3020
3130
  } catch(e) { console.log('parse_error'); }
3021
3131
  " 2>/dev/null)
3132
+ # NOTE (#555): the 80 below is deliberately NOT $COVERAGE_THRESHOLD.
3133
+ # That one is the project-level floor a repo declares in fail_under;
3134
+ # this is the bar a single NEW file must clear to justify existing at
3135
+ # all. Tying them would mean a repo with fail_under=82 could not add a
3136
+ # file at 81% even while the project sits well above its floor.
3022
3137
  if [ "$FILE_COV" = "no_data" ] || [ "$FILE_COV" = "parse_error" ]; then
3023
3138
  echo " ⚠️ $new_file — no coverage data (file may be excluded or not exercised)"
3024
3139
  elif [ "$FILE_COV" -lt 80 ]; then
3025
- echo " ❌ $new_file — ${FILE_COV}% coverage (below 80% threshold)"
3140
+ echo " ❌ $new_file — ${FILE_COV}% coverage (below 80% per-new-file threshold)"
3026
3141
  NEW_FILE_COV_FAIL=true
3027
3142
  else
3028
3143
  echo " ✅ $new_file — ${FILE_COV}% coverage"
@@ -3036,8 +3151,13 @@ else
3036
3151
  fi
3037
3152
  fi
3038
3153
  fi
3154
+ # Only reached on the non-docs-only path. Assigning PASS outside the if/else
3155
+ # overwrote the docs-only SKIP a few hundred lines above and reported
3156
+ # "Tests + Coverage: PASS (coverage: N/A)" for a commit where no test ran
3157
+ # (#554) -- a SKIP promoted to PASS inflates the pass rate and hides that the
3158
+ # gate never executed.
3159
+ GATE_5_STATUS="PASS"
3039
3160
  fi
3040
- GATE_5_STATUS="PASS"
3041
3161
  record_gate_audit "gate-5" "tests-coverage" "$GATE_5_STATUS" "0" "$GATE_5_START" "${GATE_5_DETAIL:-}"
3042
3162
 
3043
3163
  # ============================================================================
@@ -3582,7 +3702,7 @@ echo ""
3582
3702
  # v0.14.3+ (Issue #312): Skip if no code files changed
3583
3703
  # v0.18.5 (Issue #397): Fix gate status variable collision — SAST now sets GATE_10_STATUS
3584
3704
  # ============================================================================
3585
- if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh)$"; then
3705
+ if any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|dart|sh|ps1|psm1)$"; then
3586
3706
  if [ -f "$GATE_DIR/gate-10.sh" ]; then
3587
3707
  source "$GATE_DIR/gate-10.sh"
3588
3708
  else
@@ -3885,7 +4005,8 @@ generate_quality_report() {
3885
4005
  "gate5_tests": {
3886
4006
  "name": "Tests + Coverage",
3887
4007
  "status": "${GATE_5_STATUS:-PASS}",
3888
- "thresholds": { "lines": 80, "functions": 80, "branches": 70, "statements": 80 },
4008
+ "thresholds": { "lines": ${COVERAGE_THRESHOLD}, "functions": null, "branches": null, "statements": null },
4009
+ "threshold_source": "${COVERAGE_THRESHOLD_SOURCE}",
3889
4010
  "actual": { "coverage": "${COV_PCT}" },
3890
4011
  "failedTests": $FAILED_TESTS_JSON
3891
4012
  },