@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.
- package/adapters/gate-9.sh +13 -2
- package/build-integrity/gate-10.ts +290 -263
- package/gate-10.sh +25 -6
- package/gate-12-file-hygiene.sh +35 -1
- package/gate-3.sh +78 -2
- package/gate-9.sh +13 -2
- package/hooks/gate-10.sh +25 -6
- package/hooks/gate-12-file-hygiene.sh +35 -1
- package/hooks/gate-3.sh +78 -2
- package/hooks/gate-9.sh +13 -2
- package/hooks/lib/audit-sanitize.sh +51 -0
- package/hooks/lib/ccn-ratchet.sh +183 -0
- package/hooks/lib/docs-only.sh +49 -0
- package/hooks/pre-commit +74 -15
- package/hooks/pre-push +19 -4
- package/lib/gate-audit.ts +9 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +27 -6
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/many-exports-reexport.test.ts +40 -0
- package/principles/adapters/typescript.ts +61 -30
- package/principles/boy-scout.ts +22 -17
- package/scripts/delphi-external-review.cjs +195 -132
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- 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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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 [
|
|
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
|
-
|
|
1035
|
-
echo "
|
|
1036
|
-
echo "
|
|
1037
|
-
echo "
|
|
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
|
};
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 (
|
|
117
|
-
|
|
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
|
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-19
|
|
4
|
+
**Commit:** 07d3036f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-19
|
|
4
|
+
**Commit:** 07d3036f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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": "xp-gate",
|
|
3
|
-
"version": "0.
|
|
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-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-19
|
|
4
|
+
**Commit:** 07d3036f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-19
|
|
4
|
+
**Commit:** 07d3036f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-19
|
|
4
|
+
**Commit:** 07d3036f
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 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.
|