@boyingliu01/xp-gate 0.27.6 → 0.28.1

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 (104) hide show
  1. package/adapter-common.sh +0 -0
  2. package/adapters/cpp.sh +0 -0
  3. package/adapters/dart.sh +0 -0
  4. package/adapters/flutter.sh +0 -0
  5. package/adapters/gate-9.sh +13 -2
  6. package/adapters/go.sh +0 -0
  7. package/adapters/iac.sh +0 -0
  8. package/adapters/java.sh +0 -0
  9. package/adapters/kotlin.sh +0 -0
  10. package/adapters/objectivec.sh +0 -0
  11. package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
  12. package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
  13. package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
  14. package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
  15. package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
  16. package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
  17. package/adapters/powershell.sh +0 -0
  18. package/adapters/python.sh +0 -0
  19. package/adapters/shell.sh +0 -0
  20. package/adapters/swift.sh +0 -0
  21. package/adapters/typescript.sh +0 -0
  22. package/bin/xp-gate.js +0 -0
  23. package/build-integrity/gate-10.ts +290 -263
  24. package/gate-10.sh +25 -6
  25. package/gate-12-file-hygiene.sh +35 -1
  26. package/gate-3.sh +78 -2
  27. package/gate-4.sh +0 -0
  28. package/gate-7.sh +0 -0
  29. package/gate-8.sh +0 -0
  30. package/gate-9.sh +13 -2
  31. package/hooks/adapter-common.sh +0 -0
  32. package/hooks/gate-10.sh +25 -6
  33. package/hooks/gate-12-file-hygiene.sh +35 -1
  34. package/hooks/gate-3.sh +78 -2
  35. package/hooks/gate-4.sh +0 -0
  36. package/hooks/gate-7.sh +0 -0
  37. package/hooks/gate-8.sh +0 -0
  38. package/hooks/gate-9.sh +13 -2
  39. package/hooks/lib/audit-sanitize.sh +51 -0
  40. package/hooks/lib/ccn-ratchet.sh +183 -0
  41. package/hooks/lib/docs-only.sh +49 -0
  42. package/hooks/post-merge +0 -0
  43. package/hooks/pre-commit +74 -15
  44. package/hooks/pre-push +36 -5
  45. package/hooks/sprint-gate.sh +0 -0
  46. package/lib/__tests__/sprint-audit.test.js +207 -207
  47. package/lib/gate-audit.ts +9 -4
  48. package/lib/mattpocock-license-verdict.json +1 -1
  49. package/lib/retro.js +27 -6
  50. package/lib/sprint-audit.js +235 -235
  51. package/mock-policy/AGENTS.md +3 -3
  52. package/mutation/AGENTS.md +3 -3
  53. package/package.json +1 -1
  54. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  55. package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
  56. package/plugins/claude-code/bin/xp-gate-check +0 -0
  57. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/package.json +1 -1
  61. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  62. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  63. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  64. package/plugins/opencode/package.json +1 -1
  65. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
  69. package/plugins/qoder/bin/xp-gate-check +0 -0
  70. package/plugins/qoder/plugin.json +1 -1
  71. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  73. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  74. package/principles/AGENTS.md +3 -3
  75. package/principles/__tests__/many-exports-reexport.test.ts +40 -0
  76. package/principles/adapters/typescript.ts +61 -30
  77. package/principles/boy-scout.ts +22 -17
  78. package/scripts/delphi-external-review.cjs +195 -132
  79. package/skills/delphi-review/AGENTS.md +3 -3
  80. package/skills/sprint-flow/AGENTS.md +3 -3
  81. package/skills/test-specification-alignment/AGENTS.md +3 -3
  82. package/sprint-gate.sh +0 -0
  83. package/plugins/dsh/lib/command.d.ts +0 -32
  84. package/plugins/dsh/lib/command.d.ts.map +0 -1
  85. package/plugins/dsh/lib/command.js +0 -81
  86. package/plugins/dsh/lib/delphi-config.d.ts +0 -75
  87. package/plugins/dsh/lib/delphi-config.d.ts.map +0 -1
  88. package/plugins/dsh/lib/delphi-config.js +0 -183
  89. package/plugins/dsh/lib/delphi-run.d.ts +0 -24
  90. package/plugins/dsh/lib/delphi-run.d.ts.map +0 -1
  91. package/plugins/dsh/lib/delphi-run.js +0 -92
  92. package/plugins/dsh/lib/delphi.d.ts +0 -88
  93. package/plugins/dsh/lib/delphi.d.ts.map +0 -1
  94. package/plugins/dsh/lib/delphi.js +0 -161
  95. package/plugins/dsh/lib/dialect.d.ts +0 -24
  96. package/plugins/dsh/lib/dialect.d.ts.map +0 -1
  97. package/plugins/dsh/lib/dialect.js +0 -36
  98. package/plugins/dsh/lib/gate-runner.d.ts +0 -18
  99. package/plugins/dsh/lib/gate-runner.d.ts.map +0 -1
  100. package/plugins/dsh/lib/gate-runner.js +0 -46
  101. package/plugins/dsh/lib/index.d.ts +0 -5
  102. package/plugins/dsh/lib/index.d.ts.map +0 -1
  103. package/plugins/dsh/lib/index.js +0 -162
  104. package/plugins/dsh/node_modules/.vite/vitest/results.json +0 -1
package/gate-10.sh CHANGED
@@ -53,12 +53,31 @@ else
53
53
  # lines to `read`; bash never rescans the result for separators. Its correct
54
54
  # reading rests on one property of the producer above, not of the loop:
55
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
56
  # by AC-490-04.
57
+ #
58
+ # #543: semgrep 1.171 ignores .semgrepignore ENTIRELY when files are passed
59
+ # as explicit targets (measured: file target -> an ignored finding survives
60
+ # every suppression form — ignore patterns, --exclude, --exclude-rule,
61
+ # --project-root; directory target + --include -> the ignore file is honored,
62
+ # proven fail-closed by removing it and watching the findings return). The
63
+ # gate therefore passes each staged file's DIRECTORY as a target plus one
64
+ # --include per staged basename. Coverage stays exact for the staged set
65
+ # (verified: scanned == staged); the only widening is same-basename siblings
66
+ # outside the staged set, which scan too — scanning MORE is the safe side.
59
67
  SEMGREP_ARGS=()
68
+ SEMGREP_FILE_COUNT=0
69
+ SEMGREP_DIRS_SEEN=$'\n'
60
70
  while IFS= read -r _semgrep_file; do
61
- [ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
71
+ [ -n "$_semgrep_file" ] || continue
72
+ SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
73
+ SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
74
+ _semgrep_dir="./${_semgrep_file%/*}"
75
+ # A repo-root file has no directory component ("./x.ts" strips to "./").
76
+ [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
77
+ case "$SEMGREP_DIRS_SEEN" in
78
+ *"$_semgrep_dir"*) : ;;
79
+ *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
80
+ esac
62
81
  done <<EOF
63
82
  $SEMGREP_FILES
64
83
  EOF
@@ -173,12 +192,12 @@ console.log("");});
173
192
  echo "$FINDING_DETAILS"
174
193
  echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
175
194
  GATE_10_STATUS="FAIL"
176
- GATE_10_SCANNED_FILES=${#SEMGREP_ARGS[@]}
195
+ GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
177
196
  exit 1
178
197
  else
179
- GATE_10_SCANNED_FILES=${#SEMGREP_ARGS[@]}
198
+ GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
180
199
  echo ""
181
- echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
200
+ echo " ✅ PASSED - No blocking vulnerabilities (files scanned: $SEMGREP_FILE_COUNT)"
182
201
  if [ "$V_WARNINGS" -gt 0 ]; then
183
202
  echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
184
203
  echo "$FINDING_DETAILS"
@@ -25,11 +25,20 @@ if [ -z "$STAGED_FILES" ]; then
25
25
  return 0 2>/dev/null || true
26
26
  fi
27
27
 
28
+ # REQ-F2: a docs-only commit cannot introduce code regressions; conflict
29
+ # markers and YAML/JSON syntax are still checked (diff-scoped below).
30
+ if [ "${DOCS_ONLY_COMMIT:-false}" = true ]; then
31
+ echo " ⏭️ SKIPPED - File hygiene heavy checks (documentation-only commit; diff-scoped checks still run)."
32
+ GATE_12_STATUS="SKIP"
33
+ GATE_12_DOCS_ONLY=true
34
+ fi
35
+
28
36
  HYGIENE_ISSUES=0
29
37
  HYGIENE_FILES=""
30
38
 
31
39
  # --- Check 1: Trailing whitespace ---
32
40
  TRAILING_WS_FILES=""
41
+ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
33
42
  while IFS= read -r file; do
34
43
  [ -z "$file" ] && continue
35
44
  # Skip binary files by checking if file contains null bytes
@@ -43,6 +52,7 @@ while IFS= read -r file; do
43
52
  done <<EOF
44
53
  $STAGED_FILES
45
54
  EOF
55
+ fi
46
56
 
47
57
  if [ -n "$TRAILING_WS_FILES" ]; then
48
58
  echo " ⚠️ Trailing whitespace detected in:"
@@ -55,6 +65,7 @@ fi
55
65
 
56
66
  # --- Check 2: Missing EOF newline ---
57
67
  NO_EOF_NEWLINE_FILES=""
68
+ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
58
69
  while IFS= read -r file; do
59
70
  [ -z "$file" ] && continue
60
71
  # Skip binary files
@@ -72,6 +83,7 @@ while IFS= read -r file; do
72
83
  done <<EOF
73
84
  $STAGED_FILES
74
85
  EOF
86
+ fi
75
87
 
76
88
  if [ -n "$NO_EOF_NEWLINE_FILES" ]; then
77
89
  echo " ⚠️ Missing EOF newline in:"
@@ -83,7 +95,16 @@ if [ -n "$NO_EOF_NEWLINE_FILES" ]; then
83
95
  fi
84
96
 
85
97
  # --- Check 3: Merge conflict markers ---
98
+ # Runs in BOTH modes. Docs-only commits read the staged DIFF (hunk cost)
99
+ # instead of every full blob — a marker line must be ADDED to matter, so
100
+ # '^+' minus '+++' headers is the precise scope.
86
101
  CONFLICT_MARKER_FILES=""
102
+ if [ "${GATE_12_DOCS_ONLY:-false}" = true ]; then
103
+ if git diff --cached --no-color --diff-filter=d 2>/dev/null \
104
+ | grep -E '^\+' | grep -v '^+++' | grep -qE '^\+(<<<<<<<|=======|>>>>>>>)'; then
105
+ CONFLICT_MARKER_FILES="$STAGED_FILES"
106
+ fi
107
+ else
87
108
  while IFS= read -r file; do
88
109
  [ -z "$file" ] && continue
89
110
  # Skip binary files
@@ -97,6 +118,7 @@ while IFS= read -r file; do
97
118
  done <<EOF
98
119
  $STAGED_FILES
99
120
  EOF
121
+ fi
100
122
 
101
123
  if [ -n "$CONFLICT_MARKER_FILES" ]; then
102
124
  echo " ❌ Merge conflict markers detected in:"
@@ -109,6 +131,7 @@ fi
109
131
 
110
132
  # --- Check 4: Oversized files ---
111
133
  OVERSIZED_FILES=""
134
+ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
112
135
  while IFS= read -r file; do
113
136
  [ -z "$file" ] && continue
114
137
  [ ! -f "$file" ] && continue
@@ -120,6 +143,7 @@ while IFS= read -r file; do
120
143
  done <<EOF
121
144
  $STAGED_FILES
122
145
  EOF
146
+ fi
123
147
 
124
148
  if [ -n "$OVERSIZED_FILES" ]; then
125
149
  echo " ⚠️ Oversized files (>${MAX_FILE_SIZE_BYTES} bytes):"
@@ -150,6 +174,10 @@ elif command -v jq >/dev/null 2>&1; then
150
174
  JSON_CMD="jq"
151
175
  fi
152
176
 
177
+ # Check which staged YAML/JSON files actually changed (REQ-F3: a repo with 40
178
+ # YAML configs but one edited file validates ONE blob, not forty).
179
+ CHANGED_YAML_JSON=$(git diff --cached --name-only --diff-filter=d -- '*.yml' '*.yaml' '*.json' 2>/dev/null)
180
+
153
181
  while IFS= read -r file; do
154
182
  [ -z "$file" ] && continue
155
183
  [ ! -f "$file" ] && continue
@@ -192,7 +220,7 @@ let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{JSON.p
192
220
  ;;
193
221
  esac
194
222
  done <<EOF
195
- $STAGED_FILES
223
+ $CHANGED_YAML_JSON
196
224
  EOF
197
225
 
198
226
  if [ -n "$YAML_JSON_ERRORS" ]; then
@@ -207,6 +235,12 @@ elif [ "$YAML_JSON_CHECK_COUNT" -gt 0 ]; then
207
235
  fi
208
236
 
209
237
  # --- Result ---
238
+ if [ "${GATE_12_DOCS_ONLY:-false}" = true ] && [ "$HYGIENE_ISSUES" -eq 0 ]; then
239
+ echo " ✅ PASSED - File Hygiene (docs-only commit: diff-scoped conflict-marker + YAML/JSON checks clean)."
240
+ GATE_12_STATUS="PASS"
241
+ record_gate_audit "gate-12" "file-hygiene" "$GATE_12_STATUS" "0" "$GATE_12_START" "" "docs_only_change"
242
+ return 0 2>/dev/null || true
243
+ fi
210
244
  if [ "$HYGIENE_ISSUES" -eq 0 ]; then
211
245
  echo " ✅ PASSED - No file hygiene issues detected."
212
246
  GATE_12_STATUS="PASS"
package/gate-3.sh CHANGED
@@ -92,7 +92,76 @@ else
92
92
  [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
93
93
  done <<< "$CC_FILES"
94
94
  CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
95
-
95
+
96
+ # REQ-D (sprint-20261018-quality): baseline ratchet. When
97
+ # .ccn-baseline.json exists it replaces the flat threshold verdict:
98
+ # recorded functions may not get WORSE (strictly greater blocks), new
99
+ # functions face the live thresholds, improvements only hint. The
100
+ # legacy path below stays byte-for-byte for repos without a baseline.
101
+ if [ -f ".ccn-baseline.json" ]; then
102
+ # #544: lizard's raw CCN is not what the baseline was recorded with.
103
+ # The baseline comes from scripts/ccn-report.cjs, which re-derives every
104
+ # above-threshold CCN from brace-balanced source (lizard's TS parser
105
+ # loses braces on generic-arrow fields and charges neighbours). Feeding
106
+ # the ratchet raw numbers compared two different engines — a function
107
+ # at its recorded ceiling read as "worse" purely from span inflation.
108
+ # Route the CSV through the same verifier; on rows it cannot verify the
109
+ # helper exits non-zero, and this gate fails closed instead of passing
110
+ # an unvalidated number.
111
+ CC_CSV=$("$LIZARD_PATH" --csv ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
112
+ CC_REPORT="$(git rev-parse --show-toplevel 2>/dev/null)/scripts/ccn-report.cjs"
113
+ # Fail closed when the verifier is unavailable. Previously this fell
114
+ # through and graded the RAW lizard CSV, while CI hard-fails on the same
115
+ # condition — so the hook and CI could reach opposite verdicts on
116
+ # identical code, and the hook silently compared baseline numbers
117
+ # (recorded by ccn-report's brace-verified engine) against an engine it
118
+ # no longer had. Agreeing with CI is the point of having both.
119
+ if [ ! -f "$CC_REPORT" ] || ! command -v node >/dev/null 2>&1; then
120
+ echo "❌ BLOCKED - complexity verifier unavailable (scripts/ccn-report.cjs needs node)."
121
+ echo "CI fails on this same condition; refusing to grade unverified CCN locally."
122
+ GATE_3_DETAIL="mode=ratchet verifier=MISSING"
123
+ exit 1
124
+ fi
125
+ if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
126
+ _cc_verify_failed=0
127
+ CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
128
+ if [ "$_cc_verify_failed" -ne 0 ]; then
129
+ echo "❌ BLOCKED - complexity could not be span-verified; refusing to grade unverified CCN."
130
+ GATE_3_DETAIL="mode=ratchet verify=FAILED"
131
+ exit 1
132
+ fi
133
+ CC_CSV="$CC_CSV_VERIFIED"
134
+ fi
135
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
136
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
137
+ # shellcheck source=/dev/null
138
+ . "$RATCHET_LIB"
139
+ # NOTE: the thresholds must be plain assignments BEFORE the pipe. As a
140
+ # prefix to a variable assignment (`T=1 V=$(... | fn)`) they land in the
141
+ # assignment's subshell, so the function sees no thresholds AND reads an
142
+ # empty stdin — the verdict comes back empty and Gate 3 passes on zero
143
+ # rows (measured while wiring #544: same input passed or blocked
144
+ # depending only on where the prefix sat).
145
+ CCN_WARN_THRESHOLD="$CCN_THRESHOLD"
146
+ CCN_BLOCK_THRESHOLD=$((CCN_THRESHOLD * 2))
147
+ RATCHET_VERDICT=$(printf '%s\n' "$CC_CSV" | evaluate_ccn_ratchet ".ccn-baseline.json")
148
+ CC_VIOLATIONS=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^BLOCK ' || true)
149
+ CC_LOWER=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^LOWER ' || true)
150
+ printf '%s\n' "$RATCHET_VERDICT" | grep -v '^OK ' || true
151
+ if [ "${CC_LOWER:-0}" -gt 0 ]; then
152
+ echo "ℹ️ $CC_LOWER function(s) improved below baseline — refresh with: node scripts/ccn-report.cjs --write-baseline"
153
+ fi
154
+ GATE_3_DETAIL="mode=ratchet"
155
+ if [ "${CC_VIOLATIONS:-0}" -gt 0 ]; then
156
+ echo ""
157
+ echo "❌ BLOCKED - $CC_VIOLATIONS function(s) violate the complexity ratchet (.ccn-baseline.json)."
158
+ echo "Revert the increase, or refactor; refresh the baseline only via ccn-baseline-refresh commits."
159
+ exit 1
160
+ else
161
+ echo "✅ PASSED - Complexity within ratchet baseline."
162
+ GATE_3_STATUS="PASS"
163
+ fi
164
+ else
96
165
  # Parse warning count from the summary table: "Warning cnt 8"
97
166
  # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
98
167
  CC_WARNINGS=$(echo "$CC_OUTPUT" | grep "^Warning cnt" | awk '{print $NF}' | tr -d '[:space:]' | sed 's/[^0-9]//g' || true)
@@ -108,6 +177,7 @@ else
108
177
  echo "✅ PASSED - All functions within complexity threshold ($CCN_THRESHOLD)."
109
178
  GATE_3_STATUS="PASS"
110
179
  fi
180
+ fi
111
181
  else
112
182
  echo "⏭️ SKIPPED - Complexity (no source files to check)."
113
183
  GATE_3_STATUS="SKIP"
@@ -121,4 +191,10 @@ else
121
191
  GATE_3_REASON="tool_not_installed"
122
192
  fi
123
193
  fi
124
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"
194
+ # The audit's issues_found comes from whichever mode actually ran. In ratchet
195
+ # mode the count lives in CC_VIOLATIONS (functions worse than their recorded
196
+ # baseline); the flat-threshold mode parses CC_WARNINGS from lizard's own table.
197
+ # Reading only CC_WARNINGS meant every ratchet block recorded issues_found=0 —
198
+ # the gate failed while the journal claimed nothing was found.
199
+ CC_ISSUES="${CC_VIOLATIONS:-${CC_WARNINGS:-0}}"
200
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "$CC_ISSUES" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}${GATE_3_DETAIL:+ $GATE_3_DETAIL}"
package/gate-4.sh CHANGED
File without changes
package/gate-7.sh CHANGED
File without changes
package/gate-8.sh CHANGED
File without changes
package/gate-9.sh CHANGED
@@ -57,8 +57,19 @@ else
57
57
  # CR), so one line is exactly one path. Asserted for spaces and a leading '-'
58
58
  # by AC-490-04.
59
59
  SEMGREP_ARGS=()
60
+ SEMGREP_FILE_COUNT=0
61
+ SEMGREP_DIRS_SEEN=$'\n'
60
62
  while IFS= read -r _semgrep_file; do
61
- [ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
63
+ [ -n "$_semgrep_file" ] || continue
64
+ SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
65
+ SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
66
+ _semgrep_dir="./${_semgrep_file%/*}"
67
+ # A repo-root file has no directory component ("./x.ts" strips to "./").
68
+ [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
69
+ case "$SEMGREP_DIRS_SEEN" in
70
+ *"$_semgrep_dir"*) : ;;
71
+ *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
72
+ esac
62
73
  done <<EOF
63
74
  $SEMGREP_FILES
64
75
  EOF
@@ -173,7 +184,7 @@ console.log("");});
173
184
  exit 1
174
185
  else
175
186
  echo ""
176
- echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
187
+ echo " ✅ PASSED - No blocking vulnerabilities (files scanned: $SEMGREP_FILE_COUNT)"
177
188
  if [ "$V_WARNINGS" -gt 0 ]; then
178
189
  echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
179
190
  echo "$FINDING_DETAILS"
File without changes
package/hooks/gate-10.sh CHANGED
@@ -53,12 +53,31 @@ else
53
53
  # lines to `read`; bash never rescans the result for separators. Its correct
54
54
  # reading rests on one property of the producer above, not of the loop:
55
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
56
  # by AC-490-04.
57
+ #
58
+ # #543: semgrep 1.171 ignores .semgrepignore ENTIRELY when files are passed
59
+ # as explicit targets (measured: file target -> an ignored finding survives
60
+ # every suppression form — ignore patterns, --exclude, --exclude-rule,
61
+ # --project-root; directory target + --include -> the ignore file is honored,
62
+ # proven fail-closed by removing it and watching the findings return). The
63
+ # gate therefore passes each staged file's DIRECTORY as a target plus one
64
+ # --include per staged basename. Coverage stays exact for the staged set
65
+ # (verified: scanned == staged); the only widening is same-basename siblings
66
+ # outside the staged set, which scan too — scanning MORE is the safe side.
59
67
  SEMGREP_ARGS=()
68
+ SEMGREP_FILE_COUNT=0
69
+ SEMGREP_DIRS_SEEN=$'\n'
60
70
  while IFS= read -r _semgrep_file; do
61
- [ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
71
+ [ -n "$_semgrep_file" ] || continue
72
+ SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
73
+ SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
74
+ _semgrep_dir="./${_semgrep_file%/*}"
75
+ # A repo-root file has no directory component ("./x.ts" strips to "./").
76
+ [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
77
+ case "$SEMGREP_DIRS_SEEN" in
78
+ *"$_semgrep_dir"*) : ;;
79
+ *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
80
+ esac
62
81
  done <<EOF
63
82
  $SEMGREP_FILES
64
83
  EOF
@@ -173,12 +192,12 @@ console.log("");});
173
192
  echo "$FINDING_DETAILS"
174
193
  echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
175
194
  GATE_10_STATUS="FAIL"
176
- GATE_10_SCANNED_FILES=${#SEMGREP_ARGS[@]}
195
+ GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
177
196
  exit 1
178
197
  else
179
- GATE_10_SCANNED_FILES=${#SEMGREP_ARGS[@]}
198
+ GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
180
199
  echo ""
181
- echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
200
+ echo " ✅ PASSED - No blocking vulnerabilities (files scanned: $SEMGREP_FILE_COUNT)"
182
201
  if [ "$V_WARNINGS" -gt 0 ]; then
183
202
  echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
184
203
  echo "$FINDING_DETAILS"
@@ -25,11 +25,20 @@ if [ -z "$STAGED_FILES" ]; then
25
25
  return 0 2>/dev/null || true
26
26
  fi
27
27
 
28
+ # REQ-F2: a docs-only commit cannot introduce code regressions; conflict
29
+ # markers and YAML/JSON syntax are still checked (diff-scoped below).
30
+ if [ "${DOCS_ONLY_COMMIT:-false}" = true ]; then
31
+ echo " ⏭️ SKIPPED - File hygiene heavy checks (documentation-only commit; diff-scoped checks still run)."
32
+ GATE_12_STATUS="SKIP"
33
+ GATE_12_DOCS_ONLY=true
34
+ fi
35
+
28
36
  HYGIENE_ISSUES=0
29
37
  HYGIENE_FILES=""
30
38
 
31
39
  # --- Check 1: Trailing whitespace ---
32
40
  TRAILING_WS_FILES=""
41
+ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
33
42
  while IFS= read -r file; do
34
43
  [ -z "$file" ] && continue
35
44
  # Skip binary files by checking if file contains null bytes
@@ -43,6 +52,7 @@ while IFS= read -r file; do
43
52
  done <<EOF
44
53
  $STAGED_FILES
45
54
  EOF
55
+ fi
46
56
 
47
57
  if [ -n "$TRAILING_WS_FILES" ]; then
48
58
  echo " ⚠️ Trailing whitespace detected in:"
@@ -55,6 +65,7 @@ fi
55
65
 
56
66
  # --- Check 2: Missing EOF newline ---
57
67
  NO_EOF_NEWLINE_FILES=""
68
+ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
58
69
  while IFS= read -r file; do
59
70
  [ -z "$file" ] && continue
60
71
  # Skip binary files
@@ -72,6 +83,7 @@ while IFS= read -r file; do
72
83
  done <<EOF
73
84
  $STAGED_FILES
74
85
  EOF
86
+ fi
75
87
 
76
88
  if [ -n "$NO_EOF_NEWLINE_FILES" ]; then
77
89
  echo " ⚠️ Missing EOF newline in:"
@@ -83,7 +95,16 @@ if [ -n "$NO_EOF_NEWLINE_FILES" ]; then
83
95
  fi
84
96
 
85
97
  # --- Check 3: Merge conflict markers ---
98
+ # Runs in BOTH modes. Docs-only commits read the staged DIFF (hunk cost)
99
+ # instead of every full blob — a marker line must be ADDED to matter, so
100
+ # '^+' minus '+++' headers is the precise scope.
86
101
  CONFLICT_MARKER_FILES=""
102
+ if [ "${GATE_12_DOCS_ONLY:-false}" = true ]; then
103
+ if git diff --cached --no-color --diff-filter=d 2>/dev/null \
104
+ | grep -E '^\+' | grep -v '^+++' | grep -qE '^\+(<<<<<<<|=======|>>>>>>>)'; then
105
+ CONFLICT_MARKER_FILES="$STAGED_FILES"
106
+ fi
107
+ else
87
108
  while IFS= read -r file; do
88
109
  [ -z "$file" ] && continue
89
110
  # Skip binary files
@@ -97,6 +118,7 @@ while IFS= read -r file; do
97
118
  done <<EOF
98
119
  $STAGED_FILES
99
120
  EOF
121
+ fi
100
122
 
101
123
  if [ -n "$CONFLICT_MARKER_FILES" ]; then
102
124
  echo " ❌ Merge conflict markers detected in:"
@@ -109,6 +131,7 @@ fi
109
131
 
110
132
  # --- Check 4: Oversized files ---
111
133
  OVERSIZED_FILES=""
134
+ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
112
135
  while IFS= read -r file; do
113
136
  [ -z "$file" ] && continue
114
137
  [ ! -f "$file" ] && continue
@@ -120,6 +143,7 @@ while IFS= read -r file; do
120
143
  done <<EOF
121
144
  $STAGED_FILES
122
145
  EOF
146
+ fi
123
147
 
124
148
  if [ -n "$OVERSIZED_FILES" ]; then
125
149
  echo " ⚠️ Oversized files (>${MAX_FILE_SIZE_BYTES} bytes):"
@@ -150,6 +174,10 @@ elif command -v jq >/dev/null 2>&1; then
150
174
  JSON_CMD="jq"
151
175
  fi
152
176
 
177
+ # Check which staged YAML/JSON files actually changed (REQ-F3: a repo with 40
178
+ # YAML configs but one edited file validates ONE blob, not forty).
179
+ CHANGED_YAML_JSON=$(git diff --cached --name-only --diff-filter=d -- '*.yml' '*.yaml' '*.json' 2>/dev/null)
180
+
153
181
  while IFS= read -r file; do
154
182
  [ -z "$file" ] && continue
155
183
  [ ! -f "$file" ] && continue
@@ -192,7 +220,7 @@ let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{JSON.p
192
220
  ;;
193
221
  esac
194
222
  done <<EOF
195
- $STAGED_FILES
223
+ $CHANGED_YAML_JSON
196
224
  EOF
197
225
 
198
226
  if [ -n "$YAML_JSON_ERRORS" ]; then
@@ -207,6 +235,12 @@ elif [ "$YAML_JSON_CHECK_COUNT" -gt 0 ]; then
207
235
  fi
208
236
 
209
237
  # --- Result ---
238
+ if [ "${GATE_12_DOCS_ONLY:-false}" = true ] && [ "$HYGIENE_ISSUES" -eq 0 ]; then
239
+ echo " ✅ PASSED - File Hygiene (docs-only commit: diff-scoped conflict-marker + YAML/JSON checks clean)."
240
+ GATE_12_STATUS="PASS"
241
+ record_gate_audit "gate-12" "file-hygiene" "$GATE_12_STATUS" "0" "$GATE_12_START" "" "docs_only_change"
242
+ return 0 2>/dev/null || true
243
+ fi
210
244
  if [ "$HYGIENE_ISSUES" -eq 0 ]; then
211
245
  echo " ✅ PASSED - No file hygiene issues detected."
212
246
  GATE_12_STATUS="PASS"
package/hooks/gate-3.sh CHANGED
@@ -92,7 +92,76 @@ else
92
92
  [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
93
93
  done <<< "$CC_FILES"
94
94
  CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
95
-
95
+
96
+ # REQ-D (sprint-20261018-quality): baseline ratchet. When
97
+ # .ccn-baseline.json exists it replaces the flat threshold verdict:
98
+ # recorded functions may not get WORSE (strictly greater blocks), new
99
+ # functions face the live thresholds, improvements only hint. The
100
+ # legacy path below stays byte-for-byte for repos without a baseline.
101
+ if [ -f ".ccn-baseline.json" ]; then
102
+ # #544: lizard's raw CCN is not what the baseline was recorded with.
103
+ # The baseline comes from scripts/ccn-report.cjs, which re-derives every
104
+ # above-threshold CCN from brace-balanced source (lizard's TS parser
105
+ # loses braces on generic-arrow fields and charges neighbours). Feeding
106
+ # the ratchet raw numbers compared two different engines — a function
107
+ # at its recorded ceiling read as "worse" purely from span inflation.
108
+ # Route the CSV through the same verifier; on rows it cannot verify the
109
+ # helper exits non-zero, and this gate fails closed instead of passing
110
+ # an unvalidated number.
111
+ CC_CSV=$("$LIZARD_PATH" --csv ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>/dev/null || true)
112
+ CC_REPORT="$(git rev-parse --show-toplevel 2>/dev/null)/scripts/ccn-report.cjs"
113
+ # Fail closed when the verifier is unavailable. Previously this fell
114
+ # through and graded the RAW lizard CSV, while CI hard-fails on the same
115
+ # condition — so the hook and CI could reach opposite verdicts on
116
+ # identical code, and the hook silently compared baseline numbers
117
+ # (recorded by ccn-report's brace-verified engine) against an engine it
118
+ # no longer had. Agreeing with CI is the point of having both.
119
+ if [ ! -f "$CC_REPORT" ] || ! command -v node >/dev/null 2>&1; then
120
+ echo "❌ BLOCKED - complexity verifier unavailable (scripts/ccn-report.cjs needs node)."
121
+ echo "CI fails on this same condition; refusing to grade unverified CCN locally."
122
+ GATE_3_DETAIL="mode=ratchet verifier=MISSING"
123
+ exit 1
124
+ fi
125
+ if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
126
+ _cc_verify_failed=0
127
+ CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
128
+ if [ "$_cc_verify_failed" -ne 0 ]; then
129
+ echo "❌ BLOCKED - complexity could not be span-verified; refusing to grade unverified CCN."
130
+ GATE_3_DETAIL="mode=ratchet verify=FAILED"
131
+ exit 1
132
+ fi
133
+ CC_CSV="$CC_CSV_VERIFIED"
134
+ fi
135
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
136
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
137
+ # shellcheck source=/dev/null
138
+ . "$RATCHET_LIB"
139
+ # NOTE: the thresholds must be plain assignments BEFORE the pipe. As a
140
+ # prefix to a variable assignment (`T=1 V=$(... | fn)`) they land in the
141
+ # assignment's subshell, so the function sees no thresholds AND reads an
142
+ # empty stdin — the verdict comes back empty and Gate 3 passes on zero
143
+ # rows (measured while wiring #544: same input passed or blocked
144
+ # depending only on where the prefix sat).
145
+ CCN_WARN_THRESHOLD="$CCN_THRESHOLD"
146
+ CCN_BLOCK_THRESHOLD=$((CCN_THRESHOLD * 2))
147
+ RATCHET_VERDICT=$(printf '%s\n' "$CC_CSV" | evaluate_ccn_ratchet ".ccn-baseline.json")
148
+ CC_VIOLATIONS=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^BLOCK ' || true)
149
+ CC_LOWER=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^LOWER ' || true)
150
+ printf '%s\n' "$RATCHET_VERDICT" | grep -v '^OK ' || true
151
+ if [ "${CC_LOWER:-0}" -gt 0 ]; then
152
+ echo "ℹ️ $CC_LOWER function(s) improved below baseline — refresh with: node scripts/ccn-report.cjs --write-baseline"
153
+ fi
154
+ GATE_3_DETAIL="mode=ratchet"
155
+ if [ "${CC_VIOLATIONS:-0}" -gt 0 ]; then
156
+ echo ""
157
+ echo "❌ BLOCKED - $CC_VIOLATIONS function(s) violate the complexity ratchet (.ccn-baseline.json)."
158
+ echo "Revert the increase, or refactor; refresh the baseline only via ccn-baseline-refresh commits."
159
+ exit 1
160
+ else
161
+ echo "✅ PASSED - Complexity within ratchet baseline."
162
+ GATE_3_STATUS="PASS"
163
+ fi
164
+ else
96
165
  # Parse warning count from the summary table: "Warning cnt 8"
97
166
  # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
98
167
  CC_WARNINGS=$(echo "$CC_OUTPUT" | grep "^Warning cnt" | awk '{print $NF}' | tr -d '[:space:]' | sed 's/[^0-9]//g' || true)
@@ -108,6 +177,7 @@ else
108
177
  echo "✅ PASSED - All functions within complexity threshold ($CCN_THRESHOLD)."
109
178
  GATE_3_STATUS="PASS"
110
179
  fi
180
+ fi
111
181
  else
112
182
  echo "⏭️ SKIPPED - Complexity (no source files to check)."
113
183
  GATE_3_STATUS="SKIP"
@@ -121,4 +191,10 @@ else
121
191
  GATE_3_REASON="tool_not_installed"
122
192
  fi
123
193
  fi
124
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"
194
+ # The audit's issues_found comes from whichever mode actually ran. In ratchet
195
+ # mode the count lives in CC_VIOLATIONS (functions worse than their recorded
196
+ # baseline); the flat-threshold mode parses CC_WARNINGS from lizard's own table.
197
+ # Reading only CC_WARNINGS meant every ratchet block recorded issues_found=0 —
198
+ # the gate failed while the journal claimed nothing was found.
199
+ CC_ISSUES="${CC_VIOLATIONS:-${CC_WARNINGS:-0}}"
200
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "$CC_ISSUES" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}${GATE_3_DETAIL:+ $GATE_3_DETAIL}"
package/hooks/gate-4.sh CHANGED
File without changes
package/hooks/gate-7.sh CHANGED
File without changes
package/hooks/gate-8.sh CHANGED
File without changes
package/hooks/gate-9.sh CHANGED
@@ -57,8 +57,19 @@ else
57
57
  # CR), so one line is exactly one path. Asserted for spaces and a leading '-'
58
58
  # by AC-490-04.
59
59
  SEMGREP_ARGS=()
60
+ SEMGREP_FILE_COUNT=0
61
+ SEMGREP_DIRS_SEEN=$'\n'
60
62
  while IFS= read -r _semgrep_file; do
61
- [ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
63
+ [ -n "$_semgrep_file" ] || continue
64
+ SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
65
+ SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
66
+ _semgrep_dir="./${_semgrep_file%/*}"
67
+ # A repo-root file has no directory component ("./x.ts" strips to "./").
68
+ [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
69
+ case "$SEMGREP_DIRS_SEEN" in
70
+ *"$_semgrep_dir"*) : ;;
71
+ *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
72
+ esac
62
73
  done <<EOF
63
74
  $SEMGREP_FILES
64
75
  EOF
@@ -173,7 +184,7 @@ console.log("");});
173
184
  exit 1
174
185
  else
175
186
  echo ""
176
- echo " ✅ PASSED - No blocking vulnerabilities (files scanned: ${#SEMGREP_ARGS[@]})"
187
+ echo " ✅ PASSED - No blocking vulnerabilities (files scanned: $SEMGREP_FILE_COUNT)"
177
188
  if [ "$V_WARNINGS" -gt 0 ]; then
178
189
  echo " ⚠️ ${V_WARNINGS} non-blocking findings (warnings only)"
179
190
  echo "$FINDING_DETAILS"
@@ -0,0 +1,51 @@
1
+ #!/bin/bash
2
+ # ============================================================================
3
+ # audit-sanitize.sh — honest gate-duration bookkeeping (REQ-B, #536 family)
4
+ #
5
+ # .xp-gate/audit.jsonl carried 389 records whose duration_ms was an epoch-
6
+ # sized integer: a poisoned start_ms (empty -> arithmetic 0, or a SECONDS-unit
7
+ # value from a pre-#370 code path) turned `end - start` into "now". The old
8
+ # handler clamped negatives to 0 and flagged >2h runs with duration_anomaly,
9
+ # but still wrote the astronomical number itself. Fail closed on the input
10
+ # instead: when start_ms cannot be a millisecond epoch, record null + reason
11
+ # so every consumer can exclude the sample instead of averaging a lie.
12
+ #
13
+ # Source this file: source "$(dirname "${BASH_SOURCE[0]}")/lib/audit-sanitize.sh"
14
+ # ============================================================================
15
+
16
+ # Millisecond epochs are 13 digits from 2001 through 2286; seconds epochs are
17
+ # 10 digits in that whole window. A 10-digit start is therefore provably the
18
+ # wrong unit, and anything else non-numeric is corruption. Both cases yield
19
+ # "null"; a valid 13-digit value passes through untouched.
20
+ sanitize_start() {
21
+ local s="$1"
22
+ case "$s" in
23
+ ''|*[!0-9]*) printf 'null' ;;
24
+ *)
25
+ if [ "${#s}" -eq 13 ]; then
26
+ printf '%s' "$s"
27
+ else
28
+ printf 'null'
29
+ fi
30
+ ;;
31
+ esac
32
+ }
33
+
34
+ # compute_duration_ms <start_ms> <end_ms> -> delta | null
35
+ compute_duration_ms() {
36
+ local clean_end clean_start
37
+ clean_start=$(sanitize_start "$1")
38
+ clean_end=$(sanitize_start "$2")
39
+ if [ "$clean_start" = "null" ] || [ "$clean_end" = "null" ]; then
40
+ printf 'null'
41
+ return
42
+ fi
43
+ local delta=$((clean_end - clean_start))
44
+ if [ "$delta" -lt 0 ]; then
45
+ # Clock skew between two now_ms() calls is rarer than poisoning but the
46
+ # same rule applies: a negative runtime must never masquerade as 0.
47
+ printf 'null'
48
+ return
49
+ fi
50
+ printf '%s' "$delta"
51
+ }