@boyingliu01/xp-gate 0.20.1 → 0.21.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.
Files changed (84) hide show
  1. package/adapters/java.sh +373 -27
  2. package/adapters/python.sh +73 -6
  3. package/gate-10.sh +107 -64
  4. package/gate-3.sh +51 -3
  5. package/gate-4.sh +32 -1
  6. package/hooks/gate-10.sh +107 -64
  7. package/hooks/gate-3.sh +51 -3
  8. package/hooks/gate-4.sh +32 -1
  9. package/hooks/pre-commit +319 -57
  10. package/lib/__tests__/doctor-spawn-capability.test.js +116 -0
  11. package/lib/__tests__/doctor-tui-skill-freshness.test.js +49 -10
  12. package/lib/doctor-tui.js +21 -3
  13. package/lib/doctor.js +111 -8
  14. package/lib/gate-audit.ts +3 -0
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/mock-policy/AGENTS.md +4 -4
  17. package/mutation/AGENTS.md +4 -4
  18. package/package.json +1 -1
  19. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  20. package/plugins/claude-code/skills/admin-template-guidelines/SKILL.md +1 -0
  21. package/plugins/claude-code/skills/batch-grill-me/SKILL.md +1 -0
  22. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +1 -0
  23. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  24. package/plugins/claude-code/skills/domain-modeling/SKILL.md +1 -0
  25. package/plugins/claude-code/skills/grill-with-docs/SKILL.md +1 -0
  26. package/plugins/claude-code/skills/grilling/SKILL.md +1 -0
  27. package/plugins/claude-code/skills/improve-codebase-architecture/SKILL.md +1 -0
  28. package/plugins/claude-code/skills/ralph-loop/SKILL.md +1 -0
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  30. package/plugins/claude-code/skills/test-driven-development/SKILL.md +1 -0
  31. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  32. package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +1 -0
  33. package/plugins/claude-code/skills/to-issues/SKILL.md +1 -0
  34. package/plugins/dsh/package.json +1 -1
  35. package/plugins/dsh/skills/admin-template-guidelines/SKILL.md +1 -0
  36. package/plugins/dsh/skills/batch-grill-me/SKILL.md +1 -0
  37. package/plugins/dsh/skills/clipboard-vision/SKILL.md +1 -0
  38. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  39. package/plugins/dsh/skills/domain-modeling/SKILL.md +1 -0
  40. package/plugins/dsh/skills/grill-with-docs/SKILL.md +1 -0
  41. package/plugins/dsh/skills/grilling/SKILL.md +1 -0
  42. package/plugins/dsh/skills/improve-codebase-architecture/SKILL.md +1 -0
  43. package/plugins/dsh/skills/ralph-loop/SKILL.md +1 -0
  44. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  45. package/plugins/dsh/skills/test-driven-development/SKILL.md +1 -0
  46. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  47. package/plugins/dsh/skills/test-specification-alignment/SKILL.md +1 -0
  48. package/plugins/dsh/skills/to-issues/SKILL.md +1 -0
  49. package/plugins/opencode/package.json +1 -1
  50. package/plugins/opencode/skills/admin-template-guidelines/SKILL.md +1 -0
  51. package/plugins/opencode/skills/batch-grill-me/SKILL.md +1 -0
  52. package/plugins/opencode/skills/clipboard-vision/SKILL.md +1 -0
  53. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  54. package/plugins/opencode/skills/domain-modeling/SKILL.md +1 -0
  55. package/plugins/opencode/skills/grill-with-docs/SKILL.md +1 -0
  56. package/plugins/opencode/skills/grilling/SKILL.md +1 -0
  57. package/plugins/opencode/skills/improve-codebase-architecture/SKILL.md +1 -0
  58. package/plugins/opencode/skills/ralph-loop/SKILL.md +1 -0
  59. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  60. package/plugins/opencode/skills/test-driven-development/SKILL.md +1 -0
  61. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  62. package/plugins/opencode/skills/test-specification-alignment/SKILL.md +1 -0
  63. package/plugins/opencode/skills/to-issues/SKILL.md +1 -0
  64. package/plugins/qoder/plugin.json +1 -1
  65. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  66. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  67. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  68. package/principles/AGENTS.md +4 -4
  69. package/principles/adapters/__tests__/java.test.ts +317 -0
  70. package/principles/adapters/java.ts +325 -13
  71. package/skills/admin-template-guidelines/SKILL.md +1 -0
  72. package/skills/batch-grill-me/SKILL.md +1 -0
  73. package/skills/clipboard-vision/SKILL.md +1 -0
  74. package/skills/delphi-review/AGENTS.md +4 -4
  75. package/skills/domain-modeling/SKILL.md +1 -0
  76. package/skills/grill-with-docs/SKILL.md +1 -0
  77. package/skills/grilling/SKILL.md +1 -0
  78. package/skills/improve-codebase-architecture/SKILL.md +1 -0
  79. package/skills/ralph-loop/SKILL.md +1 -0
  80. package/skills/sprint-flow/AGENTS.md +4 -4
  81. package/skills/test-driven-development/SKILL.md +1 -0
  82. package/skills/test-specification-alignment/AGENTS.md +4 -4
  83. package/skills/test-specification-alignment/SKILL.md +1 -0
  84. package/skills/to-issues/SKILL.md +1 -0
package/gate-10.sh CHANGED
@@ -57,82 +57,125 @@ else
57
57
  $SEMGREP_FILES
58
58
  EOF
59
59
 
60
- # Run semgrep with JSON output
61
- # --config=p/security-audit: explicit security ruleset
62
- # --json: machine-readable output
63
- # --disable-version-check: skip network call
64
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1)
65
- SEMGREP_EXIT=$?
60
+ # The verdict is parsed from the JSON with node (see below), so node is a
61
+ # hard dependency of this gate — check it BEFORE spending a semgrep run
62
+ # (#518 R2): without it we would silently SKIP every finding as
63
+ # "unparseable" and lose the whole security net on minimal CI images.
64
+ if ! command -v node >/dev/null 2>&1; then
65
+ echo " ⚠️ WARN - node is not installed — Gate 10 needs it to parse semgrep's JSON verdict"
66
+ echo " Install node (https://nodejs.org); without it SAST findings cannot be classified"
67
+ echo " ⏭️ SKIPPED - SAST (node not installed)"
68
+ GATE_10_STATUS="SKIP"
69
+ else
66
70
 
67
- if [ "$SEMGREP_EXIT" -eq 0 ]; then
68
- echo " ✅ PASSED - No security vulnerabilities found (files scanned: ${#SEMGREP_ARGS[@]})."
69
- GATE_10_STATUS="PASS"
70
- elif [ "$SEMGREP_EXIT" -eq 1 ]; then
71
- # Findings detected - parse JSON to categorize
72
- CRITICAL_HIGH=$(echo "$SEMGREP_OUTPUT" | node -e "
73
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
74
- try{const data=JSON.parse(d);const results=data.results||[];
75
- const count=results.filter(r=>{const s=(r.extra&&r.extra.severity||'').toUpperCase();return s==='CRITICAL'||s==='HIGH';}).length;
76
- console.log(count);}catch(e){console.log('0');}
77
- });" 2>/dev/null || echo "0")
71
+ # Run semgrep with JSON output. stdout and stderr are captured SEPARATELY:
72
+ # --json writes findings to stdout, progress/summary lines to stderr, and the
73
+ # old `2>&1` merge poisoned the very JSON the verdict now depends on (#518).
74
+ # SEMGREP_OUTPUT keeps its name and role — what the tool said, printed on the
75
+ # SKIP path (AC-475-04).
76
+ #
77
+ # #518: the exit code does NOT carry the verdict. semgrep exits 0 even with
78
+ # ERROR-severity findings, and its exit-code semantics have drifted across
79
+ # versions and platforms (upstream currently reports findings WITH --error
80
+ # as exit 1 and failures as 2, but that mapping is not something a gate
81
+ # should bet on). The JSON carries what the exit code never could —
82
+ # per-severity counts — so the verdict is read from the JSON and the exit
83
+ # code is demoted to "did the tool itself run". --error stays OFF: it would
84
+ # add nothing the JSON does not already provide, and keeping it off leaves
85
+ # failure exit codes unambiguous for the #475 diagnostics path.
86
+ SEMGREP_STDOUT_FILE=$(mktemp)
87
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
88
+ SEMGREP_EXIT=$?
78
89
 
79
- MEDIUM_LOW=$(echo "$SEMGREP_OUTPUT" | node -e "
80
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
81
- try{const data=JSON.parse(d);const results=data.results||[];
82
- const count=results.filter(r=>{const s=(r.extra&&r.extra.severity||'').toUpperCase();return s==='MEDIUM'||s==='LOW';}).length;
83
- console.log(count);}catch(e){console.log('0');}
84
- });" 2>/dev/null || echo "0")
90
+ # One node pass over the JSON: first line = verdict counts, remaining lines =
91
+ # finding details. The severity vocabulary is the UNION of the two scales
92
+ # (#518): semgrep's own ERROR is its most severe tier (it maps to the old
93
+ # CRITICAL/HIGH → BLOCK intent), its WARNING/INFO align with the old
94
+ # MEDIUM/LOW → warnings-only tier. Anything OUTSIDE both lists (missing,
95
+ # empty, or a future/new tier) counts as BLOCKING — a security gate must
96
+ # fail closed on severities it does not recognize (#518 R2). Parsing
97
+ # failure degrades to PARSE=FAIL — a fail-closed SKIP below, never a PASS.
98
+ SEMGREP_VERDICT=$(node -e '
99
+ let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{
100
+ let data;try{data=JSON.parse(d);}catch(e){console.log("PARSE=FAIL RESULTS=0 ERRORS=0 BLOCKING=0 WARNINGS=0");return;}
101
+ const results=data.results||[];const errors=data.errors||[];
102
+ const sev=r=>((r.extra&&r.extra.severity)||"").toUpperCase();
103
+ const WARNV=["WARNING","INFO","MEDIUM","LOW"];
104
+ let b=0,w=0;results.forEach(r=>{if(WARNV.indexOf(sev(r))>=0)w++;else b++;});
105
+ console.log("PARSE=OK RESULTS="+results.length+" ERRORS="+errors.length+" BLOCKING="+b+" WARNINGS="+w);
106
+ results.slice(0,5).forEach(r=>{const extra=r.extra||{};const s=sev(r)||"UNKNOWN";
107
+ console.log(" ["+s+"] "+(r.check_id||"unknown"));
108
+ console.log(" "+(r.path||"unknown")+":"+((r.start&&r.start.line)||"?")+" → "+(extra.message||"").slice(0,80));
109
+ console.log("");});
110
+ });' < "$SEMGREP_STDOUT_FILE") || SEMGREP_VERDICT="PARSE=FAIL RESULTS=0 ERRORS=0 BLOCKING=0 WARNINGS=0"
85
111
 
86
- # Extract top finding details
87
- FINDING_DETAILS=$(echo "$SEMGREP_OUTPUT" | node -e "
88
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
89
- try{const data=JSON.parse(d);const results=data.results||[];
90
- results.slice(0,5).forEach(r=>{const extra=r.extra||{};
91
- const severity=(extra.severity||'UNKNOWN').toUpperCase();
92
- const ruleId=r.check_id||'unknown';const path=r.path||'unknown';
93
- const line=(r.start&&r.start.line)||'?';const msg=(extra.message||'').slice(0,80);
94
- console.log(' ['+severity+'] '+ruleId);console.log(' '+path+':'+line+' → '+msg);console.log();});
95
- }catch(e){console.log(' (Failed to parse semgrep output)');}
96
- });" 2>/dev/null || echo " (Failed to parse semgrep output)")
112
+ PARSE_LINE=$(printf '%s\n' "$SEMGREP_VERDICT" | sed -n '1p')
113
+ FINDING_DETAILS=$(printf '%s\n' "$SEMGREP_VERDICT" | sed -n '2,$p')
114
+ V_PARSE=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/^PARSE=\([A-Z]*\) .*/\1/p')
115
+ V_RESULTS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* RESULTS=\([0-9]*\) .*/\1/p')
116
+ V_ERRORS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* ERRORS=\([0-9]*\) .*/\1/p')
117
+ V_BLOCKING=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* BLOCKING=\([0-9]*\) .*/\1/p')
118
+ V_WARNINGS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* WARNINGS=\([0-9]*\)$/\1/p')
119
+ # Keep the raw stdout tail for the unparseable branch's diagnostics BEFORE
120
+ # the temp file goes away (#518 R2: stderr alone often says nothing there).
121
+ SEMGREP_STDOUT_TAIL=$(tail -5 "$SEMGREP_STDOUT_FILE" 2>/dev/null)
122
+ rm -f "$SEMGREP_STDOUT_FILE"
97
123
 
98
- if [ "$CRITICAL_HIGH" -gt 0 ]; then
99
- echo ""
100
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
101
- echo " GATE 10: Semgrep Security Gate"
102
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
103
- echo " CRITICAL/HIGH: ${CRITICAL_HIGH} ❌ BLOCKED"
104
- echo " MEDIUM/LOW: ${MEDIUM_LOW} ⚠️ warning"
105
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
106
- echo " ❌ BLOCKED — Critical/High vulnerability found"
107
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
108
- echo "$FINDING_DETAILS"
109
- echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
110
- GATE_10_STATUS="FAIL"
111
- exit 1
112
- else
113
- echo ""
114
- echo " ✅ PASSED - No critical/high vulnerabilities"
115
- if [ "$MEDIUM_LOW" -gt 0 ]; then
116
- echo " ⚠️ ${MEDIUM_LOW} medium/low findings (warnings only)"
117
- echo "$FINDING_DETAILS"
118
- fi
119
- GATE_10_STATUS="PASS"
124
+ # Fail closed: an unparseable payload OR an OK parse with any missing count
125
+ # field (verdict-line format drift) must SKIP, never silently degrade to
126
+ # PASS (#518).
127
+ if [ "$V_PARSE" != "OK" ] || [ -z "$V_RESULTS" ] || [ -z "$V_ERRORS" ] || [ -z "$V_BLOCKING" ] || [ -z "$V_WARNINGS" ]; then
128
+ # Unparseable output: nothing the exit code claims can be trusted (#518).
129
+ # Fail closed — SKIP with diagnostics, never a PASS.
130
+ echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
131
+ if [ -n "$SEMGREP_OUTPUT" ]; then
132
+ echo " ── what semgrep reported on stderr ──"
133
+ printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
120
134
  fi
121
- else
122
- # semgrep runtime error (timeout, config error, etc.)
123
- #
124
- # SEMGREP_OUTPUT is captured above and was never shown here, so an intermittent
125
- # ruleset-fetch failure looked identical to a broken install: the gate printed
126
- # "runtime error" and kept the reason to itself (#475). The verdict stays SKIP --
127
- # an operational failure must not block the commit, and must not be called a pass.
128
- echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
135
+ if [ -n "$SEMGREP_STDOUT_TAIL" ]; then
136
+ echo " ── what semgrep wrote to stdout (tail) ──"
137
+ printf '%s\n' "$SEMGREP_STDOUT_TAIL" | sed 's/^/ /'
138
+ fi
139
+ echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
140
+ GATE_10_STATUS="SKIP"
141
+ elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
142
+ # Nothing was scanned to a verdict: semgrep either failed outright
143
+ # (nonzero exit) or completed but reported scan errors for every input
144
+ # (#518 R2: exit 0 + results:[] + errors:[{...}] used to fall through to
145
+ # PASS). An operational failure must neither block nor claim a pass
146
+ # (#475 kept).
147
+ echo " ⚠️ semgrep exit=${SEMGREP_EXIT}, scan errors=${V_ERRORS}, findings=${V_RESULTS} — nothing to judge"
129
148
  if [ -n "$SEMGREP_OUTPUT" ]; then
130
149
  echo " ── what semgrep reported ──"
131
150
  printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
132
151
  fi
133
152
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
134
153
  GATE_10_STATUS="SKIP"
154
+ elif [ "$V_BLOCKING" -gt 0 ]; then
155
+ echo ""
156
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
157
+ echo " GATE 10: Semgrep Security Gate"
158
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
159
+ echo " CRITICAL/HIGH/ERROR: ${V_BLOCKING} ❌ BLOCKED"
160
+ echo " MEDIUM/LOW/WARNING/INFO: ${V_WARNINGS} ⚠️ warning"
161
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
162
+ echo " ❌ BLOCKED — blocking-severity vulnerability found"
163
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
164
+ echo "$FINDING_DETAILS"
165
+ echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
166
+ GATE_10_STATUS="FAIL"
167
+ exit 1
168
+ else
169
+ echo ""
170
+ echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
171
+ if [ "$V_WARNINGS" -gt 0 ]; then
172
+ echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
173
+ echo "$FINDING_DETAILS"
174
+ fi
175
+ GATE_10_STATUS="PASS"
135
176
  fi
177
+
178
+ fi # node available
136
179
  fi
137
180
  fi
138
181
  record_gate_audit "gate-10" "sast-security" "$GATE_10_STATUS" "0" "$GATE_10_START"
package/gate-3.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 3: Cyclomatic Complexity Check
3
4
  # Uses lizard - checks cyclomatic complexity of changed source files
@@ -17,7 +18,45 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
17
18
  GATE_3_STATUS="SKIP"
18
19
 
19
20
  else
20
- CCN_THRESHOLD=5
21
+ # S2 (#507): three-level CCN threshold override --
22
+ # .xp-gate/ccn-threshold (positive integer) > XP_GATE_CCN_THRESHOLD > 5.
23
+ # Invalid values WARN on stderr and fall back to the default; the resolved
24
+ # value reaches the gate-3 audit record via the --detail argument below.
25
+ # Implemented with bash builtins only (no tr/grep spawns): every process
26
+ # spawn is expensive on Windows under AV filter drivers.
27
+ resolve_ccn_threshold() {
28
+ local default=5 val
29
+ if [ -f ".xp-gate/ccn-threshold" ]; then
30
+ val=$(<".xp-gate/ccn-threshold")
31
+ val="${val//[[:space:]]/}"
32
+ case "$val" in
33
+ ''|*[!0-9]*|0)
34
+ echo "WARN - invalid .xp-gate/ccn-threshold value '${val}' (expected a positive integer), falling back to ${default}" >&2
35
+ ;;
36
+ *)
37
+ echo "$val"
38
+ return 0
39
+ ;;
40
+ esac
41
+ echo "$default"
42
+ return 0
43
+ fi
44
+ val=${XP_GATE_CCN_THRESHOLD:-}
45
+ if [ -n "$val" ]; then
46
+ case "$val" in
47
+ ''|*[!0-9]*|0)
48
+ echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '${val}' (expected a positive integer), falling back to ${default}" >&2
49
+ ;;
50
+ *)
51
+ echo "$val"
52
+ return 0
53
+ ;;
54
+ esac
55
+ fi
56
+ echo "$default"
57
+ }
58
+
59
+ CCN_THRESHOLD=$(resolve_ccn_threshold)
21
60
 
22
61
  # Check lizard availability
23
62
  LIZARD_CMD=""
@@ -37,7 +76,16 @@ else
37
76
  echo "Checking complexity for source files..."
38
77
 
39
78
  # Run lizard with CCN threshold
40
- CC_OUTPUT=$($LIZARD_PATH -C $CCN_THRESHOLD $CC_FILES 2>&1 || true)
79
+ # One path per line, into an array. Unquoted $CC_FILES relied on word
80
+ # splitting, which also splits any path containing a space -- handing
81
+ # lizard two bogus paths and checking neither (#457 defect class, same
82
+ # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
83
+ # collapse the list into one argument. An array does both.
84
+ CC_FILES_ARGS=()
85
+ while IFS= read -r _cc_file; do
86
+ [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
87
+ done <<< "$CC_FILES"
88
+ CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
41
89
 
42
90
  # Parse warning count from the summary table: "Warning cnt 8"
43
91
  # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
@@ -65,4 +113,4 @@ else
65
113
  GATE_3_STATUS="WARN"
66
114
  fi
67
115
  fi
68
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START"
116
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"
package/gate-4.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 4: Principles Checker (Clean Code + SOLID)
3
4
  # Reuses existing principles checker logic
@@ -23,6 +24,31 @@ if ! declare -F run_tsx >/dev/null 2>&1; then
23
24
  }
24
25
  fi
25
26
 
27
+ # Java rule coverage annotation (#507 S4, AC-507-04-02). The normative matrix
28
+ # lives in docs/java-principles-coverage.md; these constants mirror it. Before
29
+ # #507 the Java adapter fed rules only {name,type,line,code}, so the 12 rules
30
+ # reading structured metrics silently saw `undefined` and a Java commit got a
31
+ # green "checked" with zero effective coverage. 12/15 now fire; the three
32
+ # inert ones are rule-side gaps documented in the coverage doc -- they must
33
+ # NOT be announced as covered.
34
+ JAVA_COVERAGE_EFFECTIVE=12
35
+ JAVA_COVERAGE_TOTAL=15
36
+ JAVA_COVERAGE_INERT="lsp, many-exports, code-duplication"
37
+
38
+ # Print the coverage annotation when (and only when) the staged file list
39
+ # contains Java sources. Non-Java commits keep the original output byte for
40
+ # byte. Pure shell builtins: no subprocess per file.
41
+ java_coverage_note() {
42
+ local _f _has_java=""
43
+ for _f in "$@"; do
44
+ case "$_f" in
45
+ *.java|*.Java|*.JAVA) _has_java=1; break ;;
46
+ esac
47
+ done
48
+ [ -n "$_has_java" ] || return 0
49
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
50
+ }
51
+
26
52
  2>&1 echo ""
27
53
  2>&1 echo "→ Gate 4: Principles checker (Clean Code + SOLID)..."
28
54
  GATE_4_START=$(gate_start_ms)
@@ -72,7 +98,12 @@ else
72
98
 
73
99
  if [ -n "$PRINCIPLES_DIR" ]; then
74
100
  echo "Checking Clean Code + SOLID principles..."
75
-
101
+
102
+ # AC-507-04-02: say what a Java commit is actually covered for, instead
103
+ # of implying a full 15-rule check (#507). Printed before the run so it
104
+ # appears on both the PASS and the FAIL path.
105
+ java_coverage_note ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"}
106
+
76
107
  if command -v npx > /dev/null 2>&1 || [ -f "$PRINCIPLES_BASE/node_modules/tsx/dist/cli.mjs" ]; then
77
108
  # Run principles checker and store results.
78
109
  #
package/hooks/gate-10.sh CHANGED
@@ -57,82 +57,125 @@ else
57
57
  $SEMGREP_FILES
58
58
  EOF
59
59
 
60
- # Run semgrep with JSON output
61
- # --config=p/security-audit: explicit security ruleset
62
- # --json: machine-readable output
63
- # --disable-version-check: skip network call
64
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1)
65
- SEMGREP_EXIT=$?
60
+ # The verdict is parsed from the JSON with node (see below), so node is a
61
+ # hard dependency of this gate — check it BEFORE spending a semgrep run
62
+ # (#518 R2): without it we would silently SKIP every finding as
63
+ # "unparseable" and lose the whole security net on minimal CI images.
64
+ if ! command -v node >/dev/null 2>&1; then
65
+ echo " ⚠️ WARN - node is not installed — Gate 10 needs it to parse semgrep's JSON verdict"
66
+ echo " Install node (https://nodejs.org); without it SAST findings cannot be classified"
67
+ echo " ⏭️ SKIPPED - SAST (node not installed)"
68
+ GATE_10_STATUS="SKIP"
69
+ else
66
70
 
67
- if [ "$SEMGREP_EXIT" -eq 0 ]; then
68
- echo " ✅ PASSED - No security vulnerabilities found (files scanned: ${#SEMGREP_ARGS[@]})."
69
- GATE_10_STATUS="PASS"
70
- elif [ "$SEMGREP_EXIT" -eq 1 ]; then
71
- # Findings detected - parse JSON to categorize
72
- CRITICAL_HIGH=$(echo "$SEMGREP_OUTPUT" | node -e "
73
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
74
- try{const data=JSON.parse(d);const results=data.results||[];
75
- const count=results.filter(r=>{const s=(r.extra&&r.extra.severity||'').toUpperCase();return s==='CRITICAL'||s==='HIGH';}).length;
76
- console.log(count);}catch(e){console.log('0');}
77
- });" 2>/dev/null || echo "0")
71
+ # Run semgrep with JSON output. stdout and stderr are captured SEPARATELY:
72
+ # --json writes findings to stdout, progress/summary lines to stderr, and the
73
+ # old `2>&1` merge poisoned the very JSON the verdict now depends on (#518).
74
+ # SEMGREP_OUTPUT keeps its name and role — what the tool said, printed on the
75
+ # SKIP path (AC-475-04).
76
+ #
77
+ # #518: the exit code does NOT carry the verdict. semgrep exits 0 even with
78
+ # ERROR-severity findings, and its exit-code semantics have drifted across
79
+ # versions and platforms (upstream currently reports findings WITH --error
80
+ # as exit 1 and failures as 2, but that mapping is not something a gate
81
+ # should bet on). The JSON carries what the exit code never could —
82
+ # per-severity counts — so the verdict is read from the JSON and the exit
83
+ # code is demoted to "did the tool itself run". --error stays OFF: it would
84
+ # add nothing the JSON does not already provide, and keeping it off leaves
85
+ # failure exit codes unambiguous for the #475 diagnostics path.
86
+ SEMGREP_STDOUT_FILE=$(mktemp)
87
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
88
+ SEMGREP_EXIT=$?
78
89
 
79
- MEDIUM_LOW=$(echo "$SEMGREP_OUTPUT" | node -e "
80
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
81
- try{const data=JSON.parse(d);const results=data.results||[];
82
- const count=results.filter(r=>{const s=(r.extra&&r.extra.severity||'').toUpperCase();return s==='MEDIUM'||s==='LOW';}).length;
83
- console.log(count);}catch(e){console.log('0');}
84
- });" 2>/dev/null || echo "0")
90
+ # One node pass over the JSON: first line = verdict counts, remaining lines =
91
+ # finding details. The severity vocabulary is the UNION of the two scales
92
+ # (#518): semgrep's own ERROR is its most severe tier (it maps to the old
93
+ # CRITICAL/HIGH → BLOCK intent), its WARNING/INFO align with the old
94
+ # MEDIUM/LOW → warnings-only tier. Anything OUTSIDE both lists (missing,
95
+ # empty, or a future/new tier) counts as BLOCKING — a security gate must
96
+ # fail closed on severities it does not recognize (#518 R2). Parsing
97
+ # failure degrades to PARSE=FAIL — a fail-closed SKIP below, never a PASS.
98
+ SEMGREP_VERDICT=$(node -e '
99
+ let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{
100
+ let data;try{data=JSON.parse(d);}catch(e){console.log("PARSE=FAIL RESULTS=0 ERRORS=0 BLOCKING=0 WARNINGS=0");return;}
101
+ const results=data.results||[];const errors=data.errors||[];
102
+ const sev=r=>((r.extra&&r.extra.severity)||"").toUpperCase();
103
+ const WARNV=["WARNING","INFO","MEDIUM","LOW"];
104
+ let b=0,w=0;results.forEach(r=>{if(WARNV.indexOf(sev(r))>=0)w++;else b++;});
105
+ console.log("PARSE=OK RESULTS="+results.length+" ERRORS="+errors.length+" BLOCKING="+b+" WARNINGS="+w);
106
+ results.slice(0,5).forEach(r=>{const extra=r.extra||{};const s=sev(r)||"UNKNOWN";
107
+ console.log(" ["+s+"] "+(r.check_id||"unknown"));
108
+ console.log(" "+(r.path||"unknown")+":"+((r.start&&r.start.line)||"?")+" → "+(extra.message||"").slice(0,80));
109
+ console.log("");});
110
+ });' < "$SEMGREP_STDOUT_FILE") || SEMGREP_VERDICT="PARSE=FAIL RESULTS=0 ERRORS=0 BLOCKING=0 WARNINGS=0"
85
111
 
86
- # Extract top finding details
87
- FINDING_DETAILS=$(echo "$SEMGREP_OUTPUT" | node -e "
88
- let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
89
- try{const data=JSON.parse(d);const results=data.results||[];
90
- results.slice(0,5).forEach(r=>{const extra=r.extra||{};
91
- const severity=(extra.severity||'UNKNOWN').toUpperCase();
92
- const ruleId=r.check_id||'unknown';const path=r.path||'unknown';
93
- const line=(r.start&&r.start.line)||'?';const msg=(extra.message||'').slice(0,80);
94
- console.log(' ['+severity+'] '+ruleId);console.log(' '+path+':'+line+' → '+msg);console.log();});
95
- }catch(e){console.log(' (Failed to parse semgrep output)');}
96
- });" 2>/dev/null || echo " (Failed to parse semgrep output)")
112
+ PARSE_LINE=$(printf '%s\n' "$SEMGREP_VERDICT" | sed -n '1p')
113
+ FINDING_DETAILS=$(printf '%s\n' "$SEMGREP_VERDICT" | sed -n '2,$p')
114
+ V_PARSE=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/^PARSE=\([A-Z]*\) .*/\1/p')
115
+ V_RESULTS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* RESULTS=\([0-9]*\) .*/\1/p')
116
+ V_ERRORS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* ERRORS=\([0-9]*\) .*/\1/p')
117
+ V_BLOCKING=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* BLOCKING=\([0-9]*\) .*/\1/p')
118
+ V_WARNINGS=$(printf '%s\n' "$PARSE_LINE" | sed -n 's/.* WARNINGS=\([0-9]*\)$/\1/p')
119
+ # Keep the raw stdout tail for the unparseable branch's diagnostics BEFORE
120
+ # the temp file goes away (#518 R2: stderr alone often says nothing there).
121
+ SEMGREP_STDOUT_TAIL=$(tail -5 "$SEMGREP_STDOUT_FILE" 2>/dev/null)
122
+ rm -f "$SEMGREP_STDOUT_FILE"
97
123
 
98
- if [ "$CRITICAL_HIGH" -gt 0 ]; then
99
- echo ""
100
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
101
- echo " GATE 10: Semgrep Security Gate"
102
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
103
- echo " CRITICAL/HIGH: ${CRITICAL_HIGH} ❌ BLOCKED"
104
- echo " MEDIUM/LOW: ${MEDIUM_LOW} ⚠️ warning"
105
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
106
- echo " ❌ BLOCKED — Critical/High vulnerability found"
107
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
108
- echo "$FINDING_DETAILS"
109
- echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
110
- GATE_10_STATUS="FAIL"
111
- exit 1
112
- else
113
- echo ""
114
- echo " ✅ PASSED - No critical/high vulnerabilities"
115
- if [ "$MEDIUM_LOW" -gt 0 ]; then
116
- echo " ⚠️ ${MEDIUM_LOW} medium/low findings (warnings only)"
117
- echo "$FINDING_DETAILS"
118
- fi
119
- GATE_10_STATUS="PASS"
124
+ # Fail closed: an unparseable payload OR an OK parse with any missing count
125
+ # field (verdict-line format drift) must SKIP, never silently degrade to
126
+ # PASS (#518).
127
+ if [ "$V_PARSE" != "OK" ] || [ -z "$V_RESULTS" ] || [ -z "$V_ERRORS" ] || [ -z "$V_BLOCKING" ] || [ -z "$V_WARNINGS" ]; then
128
+ # Unparseable output: nothing the exit code claims can be trusted (#518).
129
+ # Fail closed — SKIP with diagnostics, never a PASS.
130
+ echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
131
+ if [ -n "$SEMGREP_OUTPUT" ]; then
132
+ echo " ── what semgrep reported on stderr ──"
133
+ printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
120
134
  fi
121
- else
122
- # semgrep runtime error (timeout, config error, etc.)
123
- #
124
- # SEMGREP_OUTPUT is captured above and was never shown here, so an intermittent
125
- # ruleset-fetch failure looked identical to a broken install: the gate printed
126
- # "runtime error" and kept the reason to itself (#475). The verdict stays SKIP --
127
- # an operational failure must not block the commit, and must not be called a pass.
128
- echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
135
+ if [ -n "$SEMGREP_STDOUT_TAIL" ]; then
136
+ echo " ── what semgrep wrote to stdout (tail) ──"
137
+ printf '%s\n' "$SEMGREP_STDOUT_TAIL" | sed 's/^/ /'
138
+ fi
139
+ echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
140
+ GATE_10_STATUS="SKIP"
141
+ elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
142
+ # Nothing was scanned to a verdict: semgrep either failed outright
143
+ # (nonzero exit) or completed but reported scan errors for every input
144
+ # (#518 R2: exit 0 + results:[] + errors:[{...}] used to fall through to
145
+ # PASS). An operational failure must neither block nor claim a pass
146
+ # (#475 kept).
147
+ echo " ⚠️ semgrep exit=${SEMGREP_EXIT}, scan errors=${V_ERRORS}, findings=${V_RESULTS} — nothing to judge"
129
148
  if [ -n "$SEMGREP_OUTPUT" ]; then
130
149
  echo " ── what semgrep reported ──"
131
150
  printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
132
151
  fi
133
152
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
134
153
  GATE_10_STATUS="SKIP"
154
+ elif [ "$V_BLOCKING" -gt 0 ]; then
155
+ echo ""
156
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
157
+ echo " GATE 10: Semgrep Security Gate"
158
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
159
+ echo " CRITICAL/HIGH/ERROR: ${V_BLOCKING} ❌ BLOCKED"
160
+ echo " MEDIUM/LOW/WARNING/INFO: ${V_WARNINGS} ⚠️ warning"
161
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
162
+ echo " ❌ BLOCKED — blocking-severity vulnerability found"
163
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
164
+ echo "$FINDING_DETAILS"
165
+ echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
166
+ GATE_10_STATUS="FAIL"
167
+ exit 1
168
+ else
169
+ echo ""
170
+ echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
171
+ if [ "$V_WARNINGS" -gt 0 ]; then
172
+ echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
173
+ echo "$FINDING_DETAILS"
174
+ fi
175
+ GATE_10_STATUS="PASS"
135
176
  fi
177
+
178
+ fi # node available
136
179
  fi
137
180
  fi
138
181
  record_gate_audit "gate-10" "sast-security" "$GATE_10_STATUS" "0" "$GATE_10_START"
package/hooks/gate-3.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 3: Cyclomatic Complexity Check
3
4
  # Uses lizard - checks cyclomatic complexity of changed source files
@@ -17,7 +18,45 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
17
18
  GATE_3_STATUS="SKIP"
18
19
 
19
20
  else
20
- CCN_THRESHOLD=5
21
+ # S2 (#507): three-level CCN threshold override --
22
+ # .xp-gate/ccn-threshold (positive integer) > XP_GATE_CCN_THRESHOLD > 5.
23
+ # Invalid values WARN on stderr and fall back to the default; the resolved
24
+ # value reaches the gate-3 audit record via the --detail argument below.
25
+ # Implemented with bash builtins only (no tr/grep spawns): every process
26
+ # spawn is expensive on Windows under AV filter drivers.
27
+ resolve_ccn_threshold() {
28
+ local default=5 val
29
+ if [ -f ".xp-gate/ccn-threshold" ]; then
30
+ val=$(<".xp-gate/ccn-threshold")
31
+ val="${val//[[:space:]]/}"
32
+ case "$val" in
33
+ ''|*[!0-9]*|0)
34
+ echo "WARN - invalid .xp-gate/ccn-threshold value '${val}' (expected a positive integer), falling back to ${default}" >&2
35
+ ;;
36
+ *)
37
+ echo "$val"
38
+ return 0
39
+ ;;
40
+ esac
41
+ echo "$default"
42
+ return 0
43
+ fi
44
+ val=${XP_GATE_CCN_THRESHOLD:-}
45
+ if [ -n "$val" ]; then
46
+ case "$val" in
47
+ ''|*[!0-9]*|0)
48
+ echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '${val}' (expected a positive integer), falling back to ${default}" >&2
49
+ ;;
50
+ *)
51
+ echo "$val"
52
+ return 0
53
+ ;;
54
+ esac
55
+ fi
56
+ echo "$default"
57
+ }
58
+
59
+ CCN_THRESHOLD=$(resolve_ccn_threshold)
21
60
 
22
61
  # Check lizard availability
23
62
  LIZARD_CMD=""
@@ -37,7 +76,16 @@ else
37
76
  echo "Checking complexity for source files..."
38
77
 
39
78
  # Run lizard with CCN threshold
40
- CC_OUTPUT=$($LIZARD_PATH -C $CCN_THRESHOLD $CC_FILES 2>&1 || true)
79
+ # One path per line, into an array. Unquoted $CC_FILES relied on word
80
+ # splitting, which also splits any path containing a space -- handing
81
+ # lizard two bogus paths and checking neither (#457 defect class, same
82
+ # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
83
+ # collapse the list into one argument. An array does both.
84
+ CC_FILES_ARGS=()
85
+ while IFS= read -r _cc_file; do
86
+ [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
87
+ done <<< "$CC_FILES"
88
+ CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
41
89
 
42
90
  # Parse warning count from the summary table: "Warning cnt 8"
43
91
  # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
@@ -65,4 +113,4 @@ else
65
113
  GATE_3_STATUS="WARN"
66
114
  fi
67
115
  fi
68
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START"
116
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"