@boyingliu01/xp-gate 0.27.5 → 0.28.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 (45) hide show
  1. package/adapters/gate-9.sh +13 -2
  2. package/build-integrity/gate-10.ts +290 -263
  3. package/gate-10.sh +25 -6
  4. package/gate-12-file-hygiene.sh +35 -1
  5. package/gate-3.sh +78 -2
  6. package/gate-9.sh +13 -2
  7. package/hooks/gate-10.sh +25 -6
  8. package/hooks/gate-12-file-hygiene.sh +35 -1
  9. package/hooks/gate-3.sh +78 -2
  10. package/hooks/gate-9.sh +13 -2
  11. package/hooks/lib/audit-sanitize.sh +51 -0
  12. package/hooks/lib/ccn-ratchet.sh +183 -0
  13. package/hooks/lib/docs-only.sh +49 -0
  14. package/hooks/pre-commit +74 -15
  15. package/hooks/pre-push +19 -4
  16. package/lib/gate-audit.ts +9 -4
  17. package/lib/mattpocock-license-verdict.json +1 -1
  18. package/lib/retro.js +27 -6
  19. package/mock-policy/AGENTS.md +3 -3
  20. package/mutation/AGENTS.md +3 -3
  21. package/package.json +1 -1
  22. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  23. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/dsh/package.json +1 -1
  27. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/plugins/opencode/package.json +1 -1
  31. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/plugins/qoder/plugin.json +1 -1
  35. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  36. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  37. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  38. package/principles/AGENTS.md +3 -3
  39. package/principles/__tests__/many-exports-reexport.test.ts +40 -0
  40. package/principles/adapters/typescript.ts +61 -30
  41. package/principles/boy-scout.ts +22 -17
  42. package/scripts/delphi-external-review.cjs +195 -132
  43. package/skills/delphi-review/AGENTS.md +3 -3
  44. package/skills/sprint-flow/AGENTS.md +3 -3
  45. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,183 @@
1
+ #!/bin/bash
2
+ # ============================================================================
3
+ # ccn-ratchet.sh — Gate 3 baseline ratchet (sprint-20261018-quality REQ-D)
4
+ #
5
+ # The repo carries years of complex-but-alive functions. Blocking them today
6
+ # would freeze the codebase; ignoring them lets complexity grow silently. The
7
+ # ratchet splits the difference: .ccn-baseline.json records the CCN each
8
+ # known function has TODAY, and the gate only fires when a recorded function
9
+ # gets WORSE (strictly greater — equal passes). Functions with no entry are
10
+ # new code and face the live warn/block thresholds unchanged. When a function
11
+ # improves we say so and suggest refreshing the baseline, because a stale
12
+ # ceiling invites nobody to finish the job.
13
+ #
14
+ # Key = "<relative/path>::<functionName>". Rename or move = new key = treated
15
+ # as a new function. That is deliberate: fuzzy matching would hide debt moves.
16
+ # `node scripts/ccn-report.cjs --write-baseline` regenerates the file after a
17
+ # legitimate refactor.
18
+ #
19
+ # evaluate_ccn_ratchet <baseline-json> reads lizard --csv rows on stdin and
20
+ # prints "OK|WARN|BLOCK|LOWER <fn> ..." verdict lines for every function row.
21
+ # ============================================================================
22
+
23
+ # Convert lizard CSV rows to unit-separator-delimited fields.
24
+ #
25
+ # The CSV is only ever parsed once, here, with the quote rules written out in
26
+ # one place; downstream code then reads positional fields with no quoting to
27
+ # worry about. gawk arrays are pass-by-value, so a helper function CANNOT fill
28
+ # a caller-visible array, and `split($0, F, ",")` tears apart lizard's quoted
29
+ # signature column (signatures contain commas). Both of those were live bugs:
30
+ # every lookup fell through to the "new function" branch, so recorded
31
+ # functions were graded against the flat threshold instead of their baseline.
32
+ #
33
+ # Output columns (this is real lizard 1.21.3 --csv output, 11 columns):
34
+ # 1 NLOC, 2 CCN, 3 token_len, 4 param_count, 5 48 (location size),
35
+ # 6 fn@start-end@path, 7 path, 8 name, 9 signature, 10 start, 11 end
36
+ #
37
+ # The separator is ASCII Unit Separator written via sprintf so the byte never
38
+ # has to survive a shell/awk escape round trip: -F'\001' is NOT a reliable way
39
+ # to name a control character (gawk treats the argument as a regex, and the
40
+ # literal backslash path silently yields a separator that matches nothing, so
41
+ # every row is read as a single field).
42
+ CSV_FS=$'\x1f'
43
+
44
+ csv_to_unit_separated() {
45
+ awk '
46
+ function emit(line, cur, i, ch, q, nf) {
47
+ nf = 1; cur = ""; q = 0
48
+ for (i = 1; i <= length(line); i++) {
49
+ ch = substr(line, i, 1)
50
+ if (q) {
51
+ if (ch == "\"") { if (substr(line, i + 1, 1) == "\"") { cur = cur "\""; i++ } else q = 0 }
52
+ else cur = cur ch
53
+ } else if (ch == "\"") q = 1
54
+ else if (ch == ",") { f[nf++] = cur; cur = "" }
55
+ else cur = cur ch
56
+ }
57
+ f[nf] = cur
58
+ out = f[1]
59
+ for (i = 2; i <= nf; i++) out = out FS_OUT f[i]
60
+ print out
61
+ }
62
+ # Build the 0x1F byte from its numeric code: shell escapes into awk strings
63
+ # are fragile across gawk/mawk/Windows builds, but printf to an awk variable
64
+ # is unambiguous.
65
+ BEGIN { FS_OUT = sprintf("%c", 0x1F) }
66
+ { emit($0) }
67
+ '
68
+ }
69
+
70
+ evaluate_ccn_ratchet() {
71
+ local baseline_file="$1"
72
+ local warn_threshold="${CCN_WARN_THRESHOLD:-5}"
73
+ local block_threshold="${CCN_BLOCK_THRESHOLD:-10}"
74
+
75
+ # Capture stdin up front: it is consumed twice (baseline rows and verdicts).
76
+ local awk_input
77
+ awk_input=$(cat)
78
+
79
+ local baseline_json=""
80
+ if [ -f "$baseline_file" ]; then
81
+ baseline_json=$(cat "$baseline_file")
82
+ fi
83
+
84
+ # Flatten {"path::fn": 12, ...} to "path::fn=12" lines without spawning a
85
+ # JSON parser per commit (every spawn is expensive on Windows AV filters).
86
+ local flat
87
+ flat=$(printf '%s' "$baseline_json" \
88
+ | tr -d '\n' \
89
+ | sed 's/^[^{]*{//; s/}[^}]*$//' \
90
+ | grep -oE '"[^"]+"[[:space:]]*:[[:space:]]*[0-9]+' \
91
+ | sed 's/[[:space:]]//g; s/":/=/; s/"//g' || true)
92
+
93
+ # An empty or unparseable baseline must not read as "everything is new code,
94
+ # nothing is protected". `{}` and a truncated file both flatten to zero lines,
95
+ # and the grader then silently degrades to flat thresholds for a repository
96
+ # that explicitly opted into the ratchet. Say so on stderr (verdict lines stay
97
+ # clean for the caller) rather than passing quietly — the caller decides
98
+ # whether an empty baseline is fatal, but it must be able to tell.
99
+ if [ -z "$flat" ]; then
100
+ echo "WARN - complexity baseline '${baseline_file}' is empty or unparseable; grading against flat thresholds only (no function is protected by the ratchet)" >&2
101
+ fi
102
+
103
+ # Convert the CSV rows once into position-only fields, then grade them. The
104
+ # CSV quote rules live in csv_to_unit_separated and nowhere else; see the
105
+ # comment there for why parsing in-place inside awk does not work.
106
+ # Columns: $1 NLOC, $2 CCN, $3 token_len, $4 param_count, $5 location size,
107
+ # $6 fn@start-end@path, $7 path, $8 name, $9 signature,
108
+ # $10 start, $11 end
109
+ local csv_rows
110
+ csv_rows=$(printf '%s\n' "$awk_input" | csv_to_unit_separated || true)
111
+
112
+ # Only function rows survive the conversion with a numeric CCN, which is what
113
+ # the $2 guard below keys on.
114
+ #
115
+ # Duplicate-name tally: baselineKey() in scripts/ccn-report.cjs keys a name by
116
+ # line when it occurs more than once in a file, because two rows can otherwise
117
+ # share one entry and each regresses against the other. The rule is mirrored
118
+ # here so the hook and CI cannot disagree; the count is computed in shell
119
+ # because awk cannot see the whole file before grading its first row.
120
+ local dup_names
121
+ dup_names=$(printf '%s\n' "$csv_rows" | awk -F"$CSV_FS" '
122
+ $2 ~ /^[0-9]+$/ {
123
+ n = split($6, p, "@")
124
+ name = p[1]
125
+ path = (n >= 3) ? p[n] : $7
126
+ gsub(/\\/, "/", path)
127
+ count[path "::" name]++
128
+ }
129
+ END {
130
+ for (k in count) if (count[k] > 1) print k
131
+ }
132
+ ' || true)
133
+
134
+ printf '%s\n' "$csv_rows" | awk -F"$CSV_FS" -v flat="$flat" -v warn="$warn_threshold" -v block="$block_threshold" -v dups="$dup_names" -v anon_marker="(anonymous)" -v anon_short="(anon)" '
135
+ BEGIN {
136
+ n = split(flat, kv, "\n")
137
+ for (i = 1; i <= n; i++) {
138
+ eq = index(kv[i], "=")
139
+ if (eq > 0) base[substr(kv[i], 1, eq - 1)] = substr(kv[i], eq + 1) + 0
140
+ }
141
+ m = split(dups, duprow, "\n")
142
+ for (i = 1; i <= m; i++) if (length(duprow[i]) > 0) dup[duprow[i]] = 1
143
+ }
144
+ # FIELD INDEXING: $1 NLOC, $2 CCN, $3 token_len, $4 param_count,
145
+ # $5 location size, $6 fn@start-end@path, $7 path, $8 name,
146
+ # $9 signature, $10 start, $11 end.
147
+ # Those two lines are the entire column contract. Earlier revisions read
148
+ # CCN out of the identity column and name/path out of a shifted column, so
149
+ # nothing ever matched its baseline entry and every recorded function was
150
+ # graded as new code against the flat thresholds.
151
+ # NOTE: this awk program is single-quoted in shell. The two hazards that
152
+ # silently broke earlier revisions: an apostrophe anywhere below, and a
153
+ # bare double-quote pair that the shell re-pairs across the awk text. The
154
+ # anonymous marker is therefore passed in as a -v value, not written inline.
155
+ $2 ~ /^[0-9]+$/ {
156
+ ccn = $2 + 0
157
+ n2 = split($6, parts, "@")
158
+ name = parts[1]
159
+ path = (n2 >= 3) ? parts[n2] : $7
160
+ gsub(/\\/, "/", path)
161
+ # Anonymous callbacks are keyed by position: a file holds many of them and
162
+ # the baseline would otherwise keep only the last one parsed, so every
163
+ # other callback was graded against the number of a different function.
164
+ is_anon = 0
165
+ if (name == anon_marker) is_anon = 1
166
+ if (name == anon_short) is_anon = 1
167
+ start = (n2 >= 3) ? parts[2] : $10
168
+ sub(/-.*/, "", start)
169
+ key = path "::" name
170
+ if (is_anon) key = path "::" anon_marker "@" start
171
+ else if ((path "::" name) in dup) key = path "::" name "@" start
172
+ if (key in base) {
173
+ if (ccn > base[key]) printf "BLOCK %s CCN=%d baseline=%d (ratchet: worse than recorded)\n", name, ccn, base[key]
174
+ else if (ccn < base[key]) printf "LOWER %s CCN=%d baseline=%d (improved — refresh baseline when convenient)\n", name, ccn, base[key]
175
+ else printf "OK %s CCN=%d at baseline\n", name, ccn
176
+ } else {
177
+ if (ccn > block) printf "BLOCK %s CCN=%d (%s exceeds block threshold)\n", name, ccn, path
178
+ else if (ccn > warn) printf "WARN %s CCN=%d (%s new/over warn threshold)\n", name, ccn, path
179
+ else printf "OK %s CCN=%d\n", name, ccn
180
+ }
181
+ }
182
+ '
183
+ }
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # ============================================================================
3
+ # docs-only.sh — commit-level documentation-only detection (REQ-F2)
4
+ #
5
+ # The pre-commit hook already knows about PROJECT-level documentation repos
6
+ # (PROJECT_LANG=documentation-only: the repo has no code files at all). This
7
+ # is the COMMIT-level sibling: xp-gate's own docs churn — typo fixes, changelog
8
+ # entries, plan docs — used to pay the full ~46s gate bill even though tests,
9
+ # jscpd, tsc and file hygiene cannot observe a markdown edit. When every
10
+ # staged file is documentation, those heavy gates SKIP with reason
11
+ # docs_only_change; light gates (secrets, version, principles) still run.
12
+ #
13
+ # Fail-closed by construction: an empty list or any single non-matching path
14
+ # prints "false" and the gates run as before.
15
+ #
16
+ # detect_docs_only_commit reads newline-separated staged paths on stdin and
17
+ # prints true|false.
18
+ # ============================================================================
19
+
20
+ detect_docs_only_commit() {
21
+ local f ext saw=0
22
+ while IFS= read -r f || [ -n "$f" ]; do
23
+ [ -n "$f" ] || continue
24
+ saw=1
25
+ # Any recognizable source extension is code, wherever it lives — a .ts
26
+ # under docs/ (sample snippets, doc tooling) still deserves its gates.
27
+ ext="${f##*.}"
28
+ case "$ext" in
29
+ ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|kts|swift|cpp|cc|c|h|hpp|m|mm|rs|rb|php|cs|dart|sh|bash|ps1|psm1)
30
+ printf 'false'
31
+ return 0
32
+ ;;
33
+ esac
34
+ case "$f" in
35
+ *.md|*.mdx|*.rst|*.txt|*.adoc) continue ;;
36
+ docs/*|docs-*/**|.github/ISSUE_TEMPLATE/**) continue ;;
37
+ *)
38
+ printf 'false'
39
+ return 0
40
+ ;;
41
+ esac
42
+ done
43
+ if [ "$saw" = 1 ]; then
44
+ printf 'true'
45
+ return 0
46
+ fi
47
+ printf 'false'
48
+ return 0
49
+ }
package/hooks/pre-commit CHANGED
@@ -243,6 +243,12 @@ source_gate_lib() {
243
243
 
244
244
  # Issue #370: source cross-platform now_ms() helper (node-preferred, regex-validated)
245
245
  source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/now-ms.sh"
246
+ # REQ-B: duration bookkeeping sanitizer used by record_gate_audit below.
247
+ source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/audit-sanitize.sh"
248
+ # REQ-F2 (sprint-20261018-quality): commit-level docs-only detection used to
249
+ # short-circuit the heavy, code-blind gates below. Distinct from the
250
+ # PROJECT-level documentation_project detection further down.
251
+ source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/docs-only.sh"
246
252
 
247
253
  # Issue #473 (+ #454): Gate 5's vitest exit-code judgement, sourced as a library
248
254
  # so the fail-closed contract is unit-testable without executing this hook.
@@ -293,16 +299,33 @@ record_gate_audit() {
293
299
  local detail="${6:-}"
294
300
  local end_ms
295
301
  end_ms=$(now_ms)
296
- local duration_ms=$((end_ms - start_ms))
297
- if [ "$duration_ms" -lt 0 ] 2>/dev/null; then
298
- duration_ms=0
302
+ # REQ-B: sanitize BEFORE subtracting. A poisoned start (empty -> arithmetic
303
+ # 0, or a seconds-unit value from an old code path) used to survive as an
304
+ # epoch-sized duration_ms that only the anomaly flag papered over. Now the
305
+ # record carries null + reason so every consumer can exclude it honestly.
306
+ local clean_start duration_reason=""
307
+ clean_start=$(sanitize_start "$start_ms")
308
+ local duration_ms
309
+ if [ "$clean_start" = "null" ]; then
310
+ duration_ms="null"
311
+ duration_reason="poisoned_start_ms"
312
+ else
313
+ duration_ms=$((end_ms - clean_start))
314
+ if [ "$duration_ms" -lt 0 ] 2>/dev/null; then
315
+ duration_ms="null"
316
+ duration_reason="clock_skew"
317
+ fi
299
318
  fi
300
319
 
301
320
  # Issue #370: detect anomalous duration (poisoned timestamp arithmetic)
302
321
  local duration_anomaly_flag=""
303
- if [ "$duration_ms" -gt "$_MAX_DURATION_MS" ] 2>/dev/null; then
322
+ if [ "$duration_ms" != "null" ] && [ "$duration_ms" -gt "$_MAX_DURATION_MS" ] 2>/dev/null; then
304
323
  duration_anomaly_flag="--duration-anomaly true"
305
324
  fi
325
+ local duration_reason_flag=""
326
+ if [ -n "$duration_reason" ]; then
327
+ duration_reason_flag="--duration-reason $duration_reason"
328
+ fi
306
329
 
307
330
  # Determine gate result: PASS or FAIL based on status variable
308
331
  local gate_result="true"
@@ -312,13 +335,14 @@ record_gate_audit() {
312
335
 
313
336
  # Fire-and-forget: audit must never block the commit
314
337
  {
315
- npx tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
338
+ run_tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
316
339
  --gate-id "$gate_id" \
317
340
  --gate-name "$gate_name" \
318
341
  --passed "$gate_result" \
319
342
  --issues-found "$issues_found" \
320
343
  --duration-ms "$duration_ms" \
321
344
  $duration_anomaly_flag \
345
+ $duration_reason_flag \
322
346
  --trigger commit \
323
347
  ${detail:+--detail "$detail"} \
324
348
  2>/dev/null
@@ -538,6 +562,14 @@ DASHBOARD_PATH_FILTER='^dashboard/'
538
562
  RAW_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
539
563
  CHANGED_FILES=$(printf '%s\n' "$RAW_CHANGED_FILES" | grep -v "$MIRROR_PATH_FILTER" | grep -v "$DASHBOARD_PATH_FILTER" || true)
540
564
 
565
+ # REQ-F2: one commit-level docs-only verdict, computed once and reused by the
566
+ # heavy gates (2/5/9/12). Light gates never consult it — a secret in a README
567
+ # is still a secret.
568
+ DOCS_ONLY_COMMIT=false
569
+ if printf '%s\n' "$CHANGED_FILES" | detect_docs_only_commit | grep -q '^true$'; then
570
+ DOCS_ONLY_COMMIT=true
571
+ fi
572
+
541
573
  if [ -z "$RAW_CHANGED_FILES" ]; then
542
574
  echo "No files changed. Skipping gates."
543
575
  exit 0
@@ -1031,14 +1063,24 @@ fi
1031
1063
  # ============================================================================
1032
1064
  2>&1 echo ""
1033
1065
  2>&1 echo "→ Gate 1: Code Quality (static analysis + linting)..."
1066
+ # REQ-B: the start stamp is taken OUTSIDE the branches below. It used to be
1067
+ # assigned only on the lint path, so every skipped Gate 1 recorded an empty
1068
+ # GATE_1_START -> duration_ms=null with reason=poisoned_start_ms. That null is
1069
+ # honest but it answers the wrong question: those commits never ran the gate, so
1070
+ # the log should say SKIP, not "measurement failed".
1071
+ GATE_1_START=$(gate_start_ms)
1072
+ GATE_1_STATUS="PASS"
1073
+ GATE_1_DETAIL=""
1034
1074
  if ! any_changed_files_match "\.(ts|tsx|js|jsx|py|go|java|kt|cpp|c|swift|m|mm|dart|sh)$" "\.ya?ml$" "\.json$" "Dockerfile" "\.tf$"; then
1035
1075
  echo "✅ PASSED - No code files changed, skipping lint."
1076
+ GATE_1_STATUS="SKIP"
1077
+ GATE_1_DETAIL="no_matching_files"
1036
1078
  else
1037
- GATE_1_START=$(gate_start_ms)
1038
-
1039
1079
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
1040
1080
  # Documentation-only project - skip static analysis
1041
1081
  echo "⏭️ SKIPPED - Code quality (documentation project)."
1082
+ GATE_1_STATUS="SKIP"
1083
+ GATE_1_DETAIL="documentation_project"
1042
1084
 
1043
1085
  else
1044
1086
  for CURRENT_LANG in $PROJECT_LANGS; do
@@ -1596,7 +1638,6 @@ GATE_1_TOOL="${PROJECT_LANGS}"
1596
1638
  # trail via the S2 detail plumbing so a WARN verdict is diagnosable, not
1597
1639
  # silent. WARN stays IN the scoring denominator (Delphi round-2 A-MAJOR-1).
1598
1640
  record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START" "${GATE_1_DETAIL:-}"
1599
-
1600
1641
  # ============================================================================
1601
1642
  # GATE 2: Duplicate Code Detection (NEW gate)
1602
1643
  # Uses jscpd, or similar tools, specific to language
@@ -1606,8 +1647,14 @@ record_gate_audit "gate-1" "code-quality" "$GATE_1_STATUS" "0" "$GATE_1_START" "
1606
1647
  GATE_2_START=$(gate_start_ms)
1607
1648
  GATE_2_STATUS="PASS"
1608
1649
 
1609
- if [ "$PROJECT_LANG" = "documentation-only" ]; then
1610
- echo "⏭️ SKIPPED - Duplicate code (documentation project)."
1650
+ if [ "$PROJECT_LANG" = "documentation-only" ] || [ "$DOCS_ONLY_COMMIT" = true ]; then
1651
+ echo "⏭️ SKIPPED - Duplicate code (documentation-only ${DOCS_ONLY_COMMIT:+commit})."
1652
+ GATE_2_STATUS="SKIP"
1653
+ # GATE_2_DETAIL feeds record_gate_audit's detail field. The previous
1654
+ # GATE_2_REASON was assigned and never read by anything, which is worse than
1655
+ # useless: it looks like the skip reason reaches the audit journal when it does
1656
+ # not (no set -u, so nothing complained).
1657
+ GATE_2_DETAIL="docs_only_change"
1611
1658
 
1612
1659
  else
1613
1660
  for CURRENT_LANG in $PROJECT_LANGS; do
@@ -1715,7 +1762,7 @@ else
1715
1762
  fi
1716
1763
  # Set at the top of this gate; a branch may already have marked it SKIP (#477). The
1717
1764
  # audit must record what happened, not what the gate defaults to.
1718
- record_gate_audit "gate-2" "duplicate-code" "$GATE_2_STATUS" "0" "$GATE_2_START"
1765
+ record_gate_audit "gate-2" "duplicate-code" "$GATE_2_STATUS" "0" "$GATE_2_START" "${GATE_2_DETAIL:-}"
1719
1766
 
1720
1767
  # ============================================================================
1721
1768
  # GATE 3: Cyclomatic Complexity Check
@@ -1744,8 +1791,10 @@ source "$GATE_DIR/gate-4.sh"
1744
1791
  2>&1 echo "→ Gate 5: Tests & coverage..."
1745
1792
  GATE_5_START=$(gate_start_ms)
1746
1793
 
1747
- if [ "$PROJECT_LANG" = "documentation-only" ]; then
1748
- echo "✅ PASSED - Skipped (documentation project)."
1794
+ if [ "$PROJECT_LANG" = "documentation-only" ] || [ "$DOCS_ONLY_COMMIT" = true ]; then
1795
+ echo "⏭️ SKIPPED - Tests & coverage (documentation-only commit — no code changed to test)."
1796
+ GATE_5_STATUS="SKIP"
1797
+ GATE_5_DETAIL="docs_only_change"
1749
1798
 
1750
1799
  else
1751
1800
  # ========================================================================
@@ -2822,7 +2871,7 @@ else
2822
2871
  fi
2823
2872
  fi
2824
2873
  GATE_5_STATUS="PASS"
2825
- record_gate_audit "gate-5" "tests-coverage" "$GATE_5_STATUS" "0" "$GATE_5_START"
2874
+ record_gate_audit "gate-5" "tests-coverage" "$GATE_5_STATUS" "0" "$GATE_5_START" "${GATE_5_DETAIL:-}"
2826
2875
 
2827
2876
  # ============================================================================
2828
2877
  # GATE 6: Architecture & Tech Debt (Combines Old Gate 8 - Boy Scout and Gate 9 - Architecture)
@@ -3183,7 +3232,17 @@ GATE_9_TS_STATUS="SKIP"
3183
3232
  GATE_9_JAVA_STATUS="SKIP"
3184
3233
  GATE_9_STATUS="SKIP"
3185
3234
 
3186
- if [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
3235
+ if [ "$DOCS_ONLY_COMMIT" = true ]; then
3236
+ # REQ-F2: tsc cannot observe a markdown edit. The commit-level docs-only
3237
+ # verdict was already consulted by Gates 2 and 5 but not here, so a pure docs
3238
+ # commit still paid ~11s for a compile that had nothing new to compile.
3239
+ # Recorded as SKIP (not PASS): nothing was verified.
3240
+ GATE_9_TS_STATUS="SKIP"
3241
+ GATE_9_JAVA_STATUS="SKIP"
3242
+ echo " ⏭️ SKIPPED - Build integrity (docs-only commit)."
3243
+ elif [ "$PROJECT_LANG" = "documentation-only" ]; then
3244
+ echo " ⏭️ SKIPPED - Build integrity (documentation project)."
3245
+ elif [ -f "package.json" ] && [ -f "tsconfig.json" ]; then
3187
3246
  GATE_9_SCRIPT=""
3188
3247
  if [ -f "$PROJECT_ROOT/src/build-integrity/gate-10.ts" ]; then
3189
3248
  GATE_9_SCRIPT="$PROJECT_ROOT/src/build-integrity/gate-10.ts"
package/hooks/pre-push CHANGED
@@ -955,6 +955,18 @@ else
955
955
  # ============================================================================
956
956
  RESULT_FILE=".code-walkthrough-result.json"
957
957
 
958
+ # Skipped on main/master by design, exactly like Gate ML and Gate UI above.
959
+ # Work is already walkthrough-reviewed on the feature branch before it lands, so
960
+ # demanding a fresh three-model sweep against the merge commit would only
961
+ # re-review identical code — and it made every release commit unpushable, since
962
+ # the release commit itself changes nothing anyone reviewed. githooks/AGENTS.md
963
+ # documented this skip; the code did not implement it (drift fixed here).
964
+ if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
965
+ echo ""
966
+ echo " ℹ️ Gate MW skipped (pushing to ${CURRENT_BRANCH} — pre-reviewed via branch walkthrough)"
967
+ echo ""
968
+ else
969
+
958
970
 
959
971
  if [ ! -f "$RESULT_FILE" ]; then
960
972
  echo ""
@@ -1019,6 +1031,7 @@ if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "
1019
1031
  echo ""
1020
1032
  exit 1
1021
1033
  fi
1034
+ fi
1022
1035
 
1023
1036
  # ============================================================================
1024
1037
  # ALL CHECKS PASSED
@@ -1031,10 +1044,12 @@ echo ""
1031
1044
  echo "Branch: $CURRENT_BRANCH"
1032
1045
  echo "Files changed: $FILES_CHANGED"
1033
1046
  echo "Lines: +$LINES_ADDED -$LINES_DELETED"
1034
- echo ""
1035
- echo "Code walkthrough result:"
1036
- echo " Commit: $EXPECTED_COMMIT"
1037
- echo " Verdict: APPROVED"
1047
+ if [ -f "$RESULT_FILE" ]; then
1048
+ echo ""
1049
+ echo "Code walkthrough result:"
1050
+ echo " Commit: $EXPECTED_COMMIT"
1051
+ echo " Verdict: APPROVED"
1052
+ fi
1038
1053
 
1039
1054
  echo ""
1040
1055
  echo "Proceeding with push..."
package/lib/gate-audit.ts CHANGED
@@ -32,11 +32,12 @@ export interface GateAuditEntry {
32
32
  gate_name: string;
33
33
  passed: boolean;
34
34
  issues_found: number;
35
- duration_ms: number;
35
+ duration_ms: number | null; // REQ-B: null = deliberately unmeasurable (see duration_reason)
36
36
  trigger: 'commit' | 'push' | 'manual';
37
37
  repo_path: string;
38
38
  commit_hash: string;
39
39
  duration_anomaly?: boolean; // Issue #370: set when duration_ms > max_duration_ms
40
+ duration_reason?: 'poisoned_start_ms' | 'clock_skew'; // REQ-B: why duration_ms is null
40
41
  detail?: string; // #507: optional gate-specific payload (e.g. effective CCN threshold)
41
42
  }
42
43
 
@@ -154,10 +155,12 @@ function aggregateByGate(entries: GateAuditEntry[]): Map<string, { total: number
154
155
  const b = buckets.get(e.gate_id) ?? { total: 0, passed: 0, ms: 0, issues: 0, anomaly_count: 0 };
155
156
  b.total += 1;
156
157
  if (e.passed) b.passed += 1;
157
- // Issue #370: exclude anomalous durations from ms aggregation
158
+ // Issue #370: exclude anomalous durations from ms aggregation. REQ-B: a
159
+ // null duration is the honest sibling of that flag — deliberately not
160
+ // measurable, so it stays out of the sums (and out of the denominator).
158
161
  if (e.duration_anomaly === true) {
159
162
  b.anomaly_count += 1;
160
- } else {
163
+ } else if (typeof e.duration_ms === 'number') {
161
164
  b.ms += e.duration_ms;
162
165
  }
163
166
  b.issues += e.issues_found;
@@ -253,12 +256,14 @@ function runCli(): void {
253
256
  gate_name: opts['gate-name'] || 'unknown',
254
257
  passed: opts['passed'] === 'true',
255
258
  issues_found: parseInt(opts['issues-found'] || '0', 10),
256
- duration_ms: parseInt(opts['duration-ms'] || '0', 10),
259
+ duration_ms: opts['duration-ms'] === 'null' ? null : parseInt(opts['duration-ms'] || '0', 10),
257
260
  trigger: (opts['trigger'] as 'commit' | 'push' | 'manual') || 'manual',
258
261
  repo_path: process.cwd(),
259
262
  commit_hash: getCommitHash(),
260
263
  // Issue #370: only set when explicitly flagged (omit field otherwise)
261
264
  ...(opts['duration-anomaly'] === 'true' ? { duration_anomaly: true } : {}),
265
+ // REQ-B: why the duration is null — never guess a number for it.
266
+ ...(opts['duration-reason'] ? { duration_reason: opts['duration-reason'] as 'poisoned_start_ms' | 'clock_skew' } : {}),
262
267
  // #507: only set when provided (omit field otherwise)
263
268
  ...(opts['detail'] ? { detail: opts['detail'] } : {}),
264
269
  };
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-18T12:53:19.772Z",
5
+ "checked_at": "2026-10-19T12:19:52.510Z",
6
6
  "source": "network"
7
7
  }
package/lib/retro.js CHANGED
@@ -15,6 +15,14 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const { execSync } = require('child_process');
17
17
 
18
+ /**
19
+ * Upper bound for a believable gate runtime. A gate that reports more than two
20
+ * hours did not take two hours; it took a corrupted clock. Used to exclude
21
+ * samples from the duration average rather than let one poisoned row dominate
22
+ * it (the symptom was an "average" of 2.2e12 ms).
23
+ */
24
+ const MAX_SANE_DURATION_MS = 2 * 60 * 60 * 1000;
25
+
18
26
  /**
19
27
  * Get git commit subjects within a date window.
20
28
  * @param {string} dir - Repository root
@@ -105,16 +113,29 @@ function buildReport(opts) {
105
113
  L.push('## Activity Summary');
106
114
  const gateEntries = audit.filter(e => e.gate_id);
107
115
  const passed = gateEntries.filter(e => e.passed).length;
108
- const normal = gateEntries.filter(e => !e.duration_anomaly);
109
- const anomalous = gateEntries.length - normal.length;
110
- const avgMs = normal.length > 0
111
- ? Math.round(normal.reduce((s, e) => s + (e.duration_ms || 0), 0) / normal.length)
116
+ // REQ-B: a duration is only usable when it is a finite non-negative number.
117
+ // `e.duration_ms || 0` used to fold null into ZERO, which both dragged the
118
+ // mean down and hid the fact that the sample was missing — the same class of
119
+ // lie as the epoch-sized values this sprint removed. The legacy
120
+ // duration_anomaly flag is no longer the only signal: a poisoned start now
121
+ // writes null outright, so anything null must be excluded and counted.
122
+ const measurable = gateEntries.filter(e => Number.isFinite(e.duration_ms) && e.duration_ms >= 0 && e.duration_ms <= MAX_SANE_DURATION_MS);
123
+ const excluded = gateEntries.length - measurable.length;
124
+ const nullDurations = gateEntries.filter(e => e.duration_ms === null).length;
125
+ const avgMs = measurable.length > 0
126
+ ? Math.round(measurable.reduce((s, e) => s + e.duration_ms, 0) / measurable.length)
112
127
  : 0;
113
128
  L.push(`- Total commits in window: ${commits.length}`);
114
129
  L.push(`- Sprint transitions recorded: ${sprints.length}`);
115
130
  L.push(`- Gate pass rate: ${gateEntries.length > 0 ? ((passed / gateEntries.length) * 100).toFixed(1) : '0.0'}% (${gateEntries.length} gates total, ${gateEntries.length - passed} failed)`);
116
- if (gateEntries.length > 0) {
117
- L.push(`- Avg gate duration: ${avgMs}ms (${anomalous} anomal${anomalous === 1 ? 'y' : 'ies'} excluded)`);
131
+ if (measurable.length > 0) {
132
+ // p50 alongside the mean: with a long tail the mean is the least useful
133
+ // statistic, and "avg gate duration: 2.2e12ms" was the visible symptom.
134
+ const sorted = measurable.map(e => e.duration_ms).sort((a, b) => a - b);
135
+ const p50 = sorted[Math.floor(sorted.length / 2)];
136
+ L.push(`- Avg gate duration: ${avgMs}ms, p50 ${p50}ms over ${measurable.length} measurable gate(s) (${excluded} excluded${nullDurations ? `, ${nullDurations} recorded null` : ''})`);
137
+ } else if (gateEntries.length > 0) {
138
+ L.push(`- Avg gate duration: no measurable samples (${excluded} of ${gateEntries.length} excluded as null or anomalous)`);
118
139
  }
119
140
  L.push('');
120
141
 
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-18
4
- **Commit:** 41792f89
3
+ **Generated:** 2026-10-19
4
+ **Commit:** 07d3036f
5
5
  **Branch:** main
6
- **Version:** 0.27.5.0
6
+ **Version:** 0.28.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-18
4
- **Commit:** 41792f89
3
+ **Generated:** 2026-10-19
4
+ **Commit:** 07d3036f
5
5
  **Branch:** main
6
- **Version:** 0.27.5.0
6
+ **Version:** 0.28.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.27.5",
3
+ "version": "0.28.0",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.27.5",
3
+ "version": "0.28.0",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-18
4
- **Commit:** 41792f89
3
+ **Generated:** 2026-10-19
4
+ **Commit:** 07d3036f
5
5
  **Branch:** main
6
- **Version:** 0.27.5.0
6
+ **Version:** 0.28.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-18
4
- **Commit:** 41792f89
3
+ **Generated:** 2026-10-19
4
+ **Commit:** 07d3036f
5
5
  **Branch:** main
6
- **Version:** 0.27.5.0
6
+ **Version:** 0.28.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-18
4
- **Commit:** 41792f89
3
+ **Generated:** 2026-10-19
4
+ **Commit:** 07d3036f
5
5
  **Branch:** main
6
- **Version:** 0.27.5.0
6
+ **Version:** 0.28.0.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.