@boyingliu01/xp-gate 0.20.2 → 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.
Files changed (44) hide show
  1. package/adapters/gate-9.sh +142 -60
  2. package/adapters/python.sh +73 -6
  3. package/bin/xp-gate.js +2 -2
  4. package/gate-10.sh +107 -64
  5. package/gate-9.sh +142 -59
  6. package/hooks/gate-10.sh +107 -64
  7. package/hooks/gate-9.sh +142 -59
  8. package/hooks/lib/coverage-total.sh +72 -0
  9. package/hooks/pre-commit +128 -44
  10. package/hooks/pre-push +32 -0
  11. package/lib/__tests__/arch.test.js +403 -0
  12. package/lib/__tests__/doctor-469-hook-env.test.js +200 -0
  13. package/lib/arch.js +336 -49
  14. package/lib/doctor.js +319 -13
  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/delphi-review/AGENTS.md +4 -4
  21. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  22. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  23. package/plugins/dsh/package.json +1 -1
  24. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  25. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  26. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  27. package/plugins/dsh/src/command.test.ts +14 -2
  28. package/plugins/dsh/src/command.ts +8 -1
  29. package/plugins/dsh/src/index.test.ts +43 -26
  30. package/plugins/dsh/src/index.ts +164 -138
  31. package/plugins/opencode/README.md +5 -2
  32. package/plugins/opencode/index.ts +215 -164
  33. package/plugins/opencode/package.json +1 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  35. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  36. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  37. package/plugins/qoder/plugin.json +1 -1
  38. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  41. package/principles/AGENTS.md +4 -4
  42. package/skills/delphi-review/AGENTS.md +4 -4
  43. package/skills/sprint-flow/AGENTS.md +4 -4
  44. package/skills/test-specification-alignment/AGENTS.md +4 -4
@@ -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,74 +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"
105
-
@@ -9,10 +9,26 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
9
  # shellcheck source=/dev/null
10
10
  source "$SCRIPT_DIR/../adapter-common.sh" 2>/dev/null || true
11
11
 
12
- run_static_analysis() {
13
- require_tool mypy "mypy" || return 1
12
+ # A mypy target must exist AND actually contain .py files: a bare directory
13
+ # check lets through TS repos' src/ (mypy exits 2 with "There are no .py[i]
14
+ # files in directory" — #513 R1 expert-B reproduction), and a file check
15
+ # alone would accept empty shell directories. Supports files too, so a
16
+ # scope declaration may name a single .py file.
17
+ _mypy_target_usable() {
18
+ [ -e "$1" ] || return 1
19
+ [ -n "$(find "$1" -name '*.py' -print 2>/dev/null | grep .)" ]
20
+ }
14
21
 
15
- # 口径 = CI type job(.github/workflows/ci.yml:43-45)的两条命令,逐字一致。
22
+ run_static_analysis() {
23
+ # NOTE: require_tool mypy runs AFTER target resolution, not here (#513 R2
24
+ # expert-C): a repo with no type surface AND no mypy installed must reach
25
+ # the SKIP branch (nothing to check) instead of false-BLOCKing on the
26
+ # missing tool. The tool is only required when there is something to check.
27
+
28
+ # 口径 = CI type job(.github/workflows/ci.yml:43-45)的目录集合(src/、
29
+ # scripts/),并按调用方式对齐:**逐目录单独调用**(#513 R1 专家 B 实测:
30
+ # 合并为 `mypy src scripts` 一次跑,src/util.py 与 scripts/util.py 同名时
31
+ # 报 "Duplicate module named" exit 2,而 CI 分开跑各自 exit 0)。
16
32
  # 为什么不能写 `mypy .`:`.` 会递归扫到两类文件,而它们在仓库里从未被声明为
17
33
  # 门禁面,CI 也从不检查:
18
34
  # ① 根目录一次性调研/演示脚本(fetch_*.py、demo_*.py、validate_*.py 等)——
@@ -28,10 +44,61 @@ run_static_analysis() {
28
44
  # 口径一致性由仓库侧 BATS 测试双向锁住:
29
45
  # githooks/__tests__/adapter-mypy-pester-scope.test.bats(含"适配器里不得再
30
46
  # 出现裸 mypy ."),防止 `mypy .` 被写回来时无人报警。
47
+ #
48
+ # #513:上述 `src/`+`scripts/` 是"单一仓库的 CI 声明面",不能当成所有 Python
49
+ # 仓库的布局硬编码——无 src/ 的仓库(如 skill-cert 的 engine/ 布局)会得到
50
+ # `mypy: can't read file 'src'` 而对任何 .py 提交恒 BLOCK,违反本项目
51
+ # "门禁无法运行 → SKIP 而非 BLOCK" 规范。口径回归"按仓库声明"三级解析:
52
+ # ① 仓库根 `.xp-gate-mypy-scope`(每行一个目录;支持整行或行内 # 注释)
53
+ # 存在则为其权威声明,声明条目同样过 usable 守卫(缺失/无 .py → WARN 跳过);
54
+ # ② 否则仅对 usable 的默认目录(src/、scripts/)执行;
55
+ # ③ 两者皆无(或声明为空)→ 打印提示并 exit 0,绝不把 mypy 空跑一次。
31
56
  echo "Running Python static analysis (mypy)..."
32
- mypy src/ || return 1
33
- mypy scripts/
34
- return $?
57
+ local -a MYPY_TARGETS=()
58
+ local _mypy_declared=0
59
+ if [ -f .xp-gate-mypy-scope ]; then
60
+ while IFS= read -r line || [ -n "$line" ]; do
61
+ line="${line%%#*}"
62
+ line="${line#"${line%%[![:space:]]*}"}"
63
+ line="${line%"${line##*[![:space:]]}"}"
64
+ [ -z "$line" ] && continue
65
+ _mypy_declared=1
66
+ if _mypy_target_usable "$line"; then
67
+ MYPY_TARGETS+=("$line")
68
+ else
69
+ echo " ⚠ mypy scope: declared target '$line' does not exist or contains no .py files — skipped"
70
+ fi
71
+ done < .xp-gate-mypy-scope
72
+ echo " scope: .xp-gate-mypy-scope declaration"
73
+ if [ "${#MYPY_TARGETS[@]}" -eq 0 ]; then
74
+ if [ "$_mypy_declared" -eq 1 ]; then
75
+ echo " ⚠ mypy: .xp-gate-mypy-scope has no usable target (all missing or without .py files) — skipping"
76
+ else
77
+ echo " ⚠ mypy: .xp-gate-mypy-scope is present but empty (comments/blank lines only) — skipping"
78
+ fi
79
+ return 0
80
+ fi
81
+ else
82
+ if [ -d src ] && _mypy_target_usable src; then
83
+ MYPY_TARGETS+=(src)
84
+ fi
85
+ if [ -d scripts ] && _mypy_target_usable scripts; then
86
+ MYPY_TARGETS+=(scripts)
87
+ fi
88
+ if [ "${#MYPY_TARGETS[@]}" -eq 0 ]; then
89
+ echo " ℹ️ mypy: repo declares no type surface (no .xp-gate-mypy-scope; src/ scripts/ absent or without .py files) — skipping"
90
+ echo " if this repo does have a type surface, declare it: create .xp-gate-mypy-scope (one directory per line)"
91
+ return 0
92
+ fi
93
+ fi
94
+ # 逐目标调用并聚合退出码(口径见函数头注释)。
95
+ require_tool mypy "mypy" || return 1
96
+ local _mypy_rc=0
97
+ local _mypy_t
98
+ for _mypy_t in "${MYPY_TARGETS[@]}"; do
99
+ mypy "$_mypy_t" || _mypy_rc=1
100
+ done
101
+ return $_mypy_rc
35
102
  }
36
103
 
37
104
  run_lint() {
package/bin/xp-gate.js CHANGED
@@ -168,9 +168,9 @@ const COMMANDS = {
168
168
  usage: 'xp-gate principles <file_or_directory> [--format console|json|sarif]'
169
169
  },
170
170
  'arch': {
171
- description: 'Run architecture validation (Gate 6 standalone, uses architecture.yaml)',
171
+ description: 'Run architecture validation (Gate 6 standalone; built-in rules, baseline ratchet when .architecture-baseline.json exists)',
172
172
  run: subargs => arch(subargs).then(code => process.exit(code)),
173
- usage: 'xp-gate arch [--config <path>]'
173
+ usage: 'xp-gate arch [--use-config <path>] (default: pinned local @archlinter/cli built-in rules; --use-config is the opt-in legacy config path)'
174
174
  },
175
175
  'check-alignment': {
176
176
  description: 'Run test-specification alignment check (deterministic, no LLM)',
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"