@boyingliu01/xp-gate 0.33.2 → 0.34.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/hooks/lib/assertion-quality.sh +232 -0
- package/hooks/lib/diff-coverage.sh +223 -0
- package/hooks/pre-commit +175 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/delphi-review/SKILL.md +30 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/claude-code/skills/test-driven-development/testing-anti-patterns.md +31 -0
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/delphi-review/SKILL.md +30 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/dsh/skills/test-driven-development/testing-anti-patterns.md +31 -0
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/delphi-review/SKILL.md +30 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/opencode/skills/test-driven-development/testing-anti-patterns.md +31 -0
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/delphi-review/SKILL.md +30 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +31 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/delphi-review/SKILL.md +30 -0
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-driven-development/SKILL.md +19 -0
- package/skills/test-driven-development/testing-anti-patterns.md +31 -0
- package/skills/test-specification-alignment/AGENTS.md +2 -2
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Test-suite hygiene detectors for Gate 5, shared by every branch that needs them.
|
|
3
|
+
# Sourced by githooks/pre-commit and by the #602/#601 test batteries; kept in its
|
|
4
|
+
# own file so the contract is unit-testable without executing the hook.
|
|
5
|
+
#
|
|
6
|
+
# Why this exists at all: 指南 §9.5/§12 says a failing build must never be "fixed"
|
|
7
|
+
# by deleting assertions, widening mocks, or skipping tests. XP-Gate had no
|
|
8
|
+
# detector for the two quietest ways to do exactly that -- a committed `.only`
|
|
9
|
+
# (CI then runs one case and reports green) and a deleted test file with no
|
|
10
|
+
# replacement (the safety net vanishes with no signal). Gate 5a guarded the
|
|
11
|
+
# ADD side ("a new source file must have a test"); these functions guard the
|
|
12
|
+
# FOCUS/SKIP/DELETE side so the constraint is symmetric.
|
|
13
|
+
#
|
|
14
|
+
# The patterns live here, once, because Gate 5d (#601, assertion quality) needs
|
|
15
|
+
# the same vocabulary. Two copies of "what counts as a skip" drift apart, and the
|
|
16
|
+
# drift is invisible: each copy looks locally correct.
|
|
17
|
+
|
|
18
|
+
# JS/TS focus markers. In vitest and jest, the presence of `.only` in a file makes
|
|
19
|
+
# the whole file run ONLY that case; every other case is silently not executed and
|
|
20
|
+
# the run still reports success. `fit`/`fdescribe` are the jasmine aliases that
|
|
21
|
+
# vitest still accepts.
|
|
22
|
+
#
|
|
23
|
+
# Only the dotted/camel spellings are matched, not a bare `fit(`: `fit(` is also a
|
|
24
|
+
# common local helper name (curve fitting, layout fitting) and a false BLOCK on
|
|
25
|
+
# ordinary source would make the gate unusable. The anchored `\b(fit|fdescribe)\(`
|
|
26
|
+
# alternative below covers the alias form at a statement boundary only.
|
|
27
|
+
#
|
|
28
|
+
# Prints `line:text` for each hit. Empty output means "no focus markers".
|
|
29
|
+
classify_test_focus() {
|
|
30
|
+
grep -nE '(\.only[[:space:]]*\()|(\b(fit|fdescribe)[[:space:]]*\()' || true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Skip/todo markers across the five languages this repo gates. These are WARNINGS,
|
|
34
|
+
# not blocks: a skipped test is sometimes legitimate and always visible in the test
|
|
35
|
+
# runner, unlike `.only`. The warning is what makes the XP-Gate report say it
|
|
36
|
+
# counted fewer cases than the file contains.
|
|
37
|
+
#
|
|
38
|
+
# `@skip-justified: <reason >= 10 chars>` exempts one occurrence; see
|
|
39
|
+
# is_skip_justified() -- a bare annotation with no reason is not an exemption, so
|
|
40
|
+
# the escape hatch cannot become the default path.
|
|
41
|
+
classify_test_skip() {
|
|
42
|
+
grep -nE '(\.(skip|todo)[[:space:]]*\()|(\b(xit|xdescribe|xtest)[[:space:]]*\()|(@pytest\.mark\.skip)|(\bt\.Skip[f]?\()|(@Disabled\b)|(@Ignore\b)' || true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# A skip marker is exempt when the surrounding file carries
|
|
46
|
+
# `@skip-justified: <reason>` with a reason of at least 10 non-space characters.
|
|
47
|
+
# Splitting marker-detection from justification keeps each rule single-purpose and
|
|
48
|
+
# makes "the annotation exists but has no reason" a testable state rather than a
|
|
49
|
+
# silent pass.
|
|
50
|
+
#
|
|
51
|
+
# $1 = the source text to inspect (usually the staged file content).
|
|
52
|
+
is_skip_justified() {
|
|
53
|
+
printf '%s\n' "$1" \
|
|
54
|
+
| grep -oE '@skip-justified:[[:space:]]*[^[:space:]*].*' \
|
|
55
|
+
| sed 's/^@skip-justified:[[:space:]]*//' \
|
|
56
|
+
| tr -d '\r' \
|
|
57
|
+
| awk 'length($0) >= 10 { found = 1; exit } END { exit(found ? 0 : 1) }'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Files whose basename marks them as a test. Shared with classify_deleted_tests so
|
|
61
|
+
# "is this a test file" has one definition. Covers the naming conventions of the
|
|
62
|
+
# gated languages: *.test.ts / *.spec.js / *_test.py / test_*.py / *_test.go /
|
|
63
|
+
# *Test.java / *Tests.kt / *.Tests.ps1.
|
|
64
|
+
is_test_file_path() {
|
|
65
|
+
printf '%s' "$1" \
|
|
66
|
+
| grep -qE '(^|/)(__tests__/)?(test_[^/]+\.py|[^/]+_test\.(py|go)|[^/]+\.(test|spec)\.[a-z]+|[^/]*(Test|Tests|TestSuite)\.(java|kt|cs|scala)|[^/]+\.Tests\.ps1)$'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Deleted test files that have NO same-named replacement.
|
|
70
|
+
#
|
|
71
|
+
# A pure deletion and a rename look identical in `--name-only --diff-filter=D`;
|
|
72
|
+
# git reports the old path as deleted either way. Comparing against the added list
|
|
73
|
+
# is what separates "the safety net was removed" from "the file moved", and
|
|
74
|
+
# reporting a rename would train developers to ignore the warning.
|
|
75
|
+
#
|
|
76
|
+
# $1 = file with deleted paths (one per line), $2 = file with added paths.
|
|
77
|
+
# Prints the offending paths; empty output means every deletion was a rename.
|
|
78
|
+
classify_deleted_tests() {
|
|
79
|
+
local deleted_list added_list
|
|
80
|
+
deleted_list="$1"
|
|
81
|
+
added_list="$2"
|
|
82
|
+
[ -f "$deleted_list" ] || return 0
|
|
83
|
+
local path
|
|
84
|
+
while IFS= read -r path; do
|
|
85
|
+
[ -n "$path" ] || continue
|
|
86
|
+
is_test_file_path "$path" || continue
|
|
87
|
+
# The same basename anywhere in the added list counts as a replacement: a
|
|
88
|
+
# rename is usually same-directory, but moving a test module into
|
|
89
|
+
# `__tests__/` is the common case here and must not be flagged.
|
|
90
|
+
local base
|
|
91
|
+
base=$(printf '%s' "$path" | sed 's#.*/##')
|
|
92
|
+
if [ -f "$added_list" ] && grep -qF "$base" "$added_list" 2>/dev/null; then
|
|
93
|
+
continue
|
|
94
|
+
fi
|
|
95
|
+
printf '%s\n' "$path"
|
|
96
|
+
done < "$deleted_list"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Gate 5d (#601): assertion quality. 指南 §9.2 forbids six assertion
|
|
101
|
+
# anti-patterns; before this, nothing in XP-Gate looked at assertions at all --
|
|
102
|
+
# Gate M2/M3 examine how test doubles are built, never whether the test actually
|
|
103
|
+
# asserts anything. `expect(true).toBe(true)` and an empty `catch {}` pass every
|
|
104
|
+
# other gate while verifying nothing.
|
|
105
|
+
#
|
|
106
|
+
# Every function below maps to one anti-pattern in
|
|
107
|
+
# skills/test-driven-development/testing-anti-patterns.md, and says so, so a
|
|
108
|
+
# reviewer reading a block message knows which section to consult.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# Anti-pattern 1: tautological assertion -- the assertion cannot fail. Two shapes,
|
|
112
|
+
# both BLOCK, because neither is a weak test: each is the absence of one wearing an
|
|
113
|
+
# assertion's syntax.
|
|
114
|
+
#
|
|
115
|
+
# (a) literal vs identical literal: `expect(true).toBe(true)`, `expect(1).toBe(1)`
|
|
116
|
+
# (b) a literal tested for the truthiness it already has:
|
|
117
|
+
# `expect(true).toBeTruthy()`, `expect(false).toBeFalsy()`,
|
|
118
|
+
# `expect("x").toBeTruthy()`, `expect(0).toBeFalsy()`, `expect(null).toBeFalsy()`
|
|
119
|
+
#
|
|
120
|
+
# (b) cannot be caught by (a)'s backreference, and the pairs are not equivalents to
|
|
121
|
+
# compare: `toBeTruthy`/`toBeFalsy` ask a different question than `toBe`. Matching
|
|
122
|
+
# the literal's CLASS against the matcher is what distinguishes them, so the two
|
|
123
|
+
# are separate alternatives rather than one clever expression.
|
|
124
|
+
#
|
|
125
|
+
# Deliberately NOT matched: `expect(x).toBe(x)` (same variable both sides). It is
|
|
126
|
+
# common in referential-identity checks (`expect(await load(a)).toBe(await load(a))`
|
|
127
|
+
# for memoisation) and a false block there is worse than the miss. Likewise
|
|
128
|
+
# `expect(someVar).toBeTruthy()` -- a variable's truthiness is a real question.
|
|
129
|
+
classify_tautological_assertions() {
|
|
130
|
+
# Implemented in awk rather than grep because POSIX grep -E has no portable
|
|
131
|
+
# backreference support: the Windows Git Bash grep used here matched nothing with
|
|
132
|
+
# `\1`, so a literal-vs-identical-literal check cannot be expressed there.
|
|
133
|
+
#
|
|
134
|
+
# The literal is extracted with a plain ERE-substitution loop instead of gawk's
|
|
135
|
+
# 3-argument match(), which mawk/BSD awk do not provide.
|
|
136
|
+
#
|
|
137
|
+
# Accepts stdin OR $1: the hook pipes the staged content in, while the test
|
|
138
|
+
# battery passes a string. Supporting only one of the two silently makes the
|
|
139
|
+
# other read nothing and report "no violations" -- the fail-open shape this
|
|
140
|
+
# whole release is about.
|
|
141
|
+
local _aq_src
|
|
142
|
+
if [ -n "${1:-}" ]; then _aq_src="$1"; else _aq_src=$(cat); fi
|
|
143
|
+
printf '%s\n' "$_aq_src" | awk '
|
|
144
|
+
BEGIN { TOL = "[[:space:]]*" }
|
|
145
|
+
function trim(s) { gsub(/^[[:space:]]+/, "", s); gsub(/[[:space:]]+$/, "", s); return s }
|
|
146
|
+
# (a) identical literal on both sides of toBe/toEqual/toStrictEqual.
|
|
147
|
+
/expect[^;]*\.to(Be|Equal|StrictEqual)[[:space:]]*\(/ {
|
|
148
|
+
line = $0
|
|
149
|
+
# Peel "expect(" ... ")" first, then the matcher argument.
|
|
150
|
+
if (match(line, /expect[[:space:]]*\([^()]*\)/)) {
|
|
151
|
+
left = substr(line, RSTART, RLENGTH)
|
|
152
|
+
sub(/^expect[[:space:]]*\(/, "", left); sub(/\)$/, "", left)
|
|
153
|
+
left = trim(left)
|
|
154
|
+
rest = substr(line, RSTART + RLENGTH)
|
|
155
|
+
if (match(rest, /\.to(Be|Equal|StrictEqual)[[:space:]]*\([^()]*\)/)) {
|
|
156
|
+
right = substr(rest, RSTART, RLENGTH)
|
|
157
|
+
sub(/^\.to(Be|Equal|StrictEqual)[[:space:]]*\(/, "", right); sub(/\)$/, "", right)
|
|
158
|
+
right = trim(right)
|
|
159
|
+
if (left != "" && left == right && left ~ /^(true|false|null|undefined|-?[0-9]+|".*"|\047.*\047)$/) {
|
|
160
|
+
print NR ":" line; next
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
# (b) a literal tested against the truthiness it already has.
|
|
166
|
+
/expect[[:space:]]*\([[:space:]]*(true|1|-1|"[^"]+"|\047[^\047]+\047)[[:space:]]*\)[[:space:]]*\.[[:space:]]*toBeTruthy[[:space:]]*\(/ { print NR ":" $0; next }
|
|
167
|
+
/expect[[:space:]]*\([[:space:]]*(false|0|null|undefined|""|\047\047)[[:space:]]*\)[[:space:]]*\.[[:space:]]*toBeFalsy[[:space:]]*\(/ { print NR ":" $0; next }
|
|
168
|
+
'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Anti-pattern 2: swallowed error -- a `catch` that neither asserts nor rethrows.
|
|
172
|
+
# The test cannot fail on the very path it was written to guard. BLOCK.
|
|
173
|
+
#
|
|
174
|
+
# Three spellings: `catch {}`, `catch (e) {}` (empty body), and `catch (e) { }`
|
|
175
|
+
# with only whitespace. A catch containing `expect`/`assert`/`throw`/`fail` is
|
|
176
|
+
# doing its job and is not matched.
|
|
177
|
+
classify_swallowed_errors() {
|
|
178
|
+
grep -nE 'catch([[:space:]]*\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' || true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Anti-patterns 3-5 (WARNING tier): weak-only assertions, mock-echo assertions, and
|
|
182
|
+
# call-order over-verification. These can be legitimate, so each is reported but
|
|
183
|
+
# never blocks.
|
|
184
|
+
#
|
|
185
|
+
# Implemented as a per-`it`/`test` block judgement rather than a per-line grep:
|
|
186
|
+
# "only weak assertions" is a property of the whole test body, and a line-wise
|
|
187
|
+
# check cannot tell `expect(x).toBeDefined()` alone from the same line followed by
|
|
188
|
+
# `expect(x).toBe(42)`.
|
|
189
|
+
#
|
|
190
|
+
# $1 = source text. Prints `line:reason` for each offending test block.
|
|
191
|
+
classify_weak_assertions() {
|
|
192
|
+
# Stdin or $1, for the same reason classify_tautological_assertions accepts both.
|
|
193
|
+
local _aq_src
|
|
194
|
+
if [ -n "${1:-}" ]; then _aq_src="$1"; else _aq_src=$(cat); fi
|
|
195
|
+
printf '%s\n' "$_aq_src" | awk '
|
|
196
|
+
# A block opens on it( / test( and closes on the matching "});" at column 0-2.
|
|
197
|
+
function flush() {
|
|
198
|
+
if (open && nassert > 0 && nstrong == 0) {
|
|
199
|
+
printf "%d:weak_assertion_only\n", startline
|
|
200
|
+
}
|
|
201
|
+
open = 0; nassert = 0; nstrong = 0
|
|
202
|
+
}
|
|
203
|
+
/(^|[^.[:alnum:]_])(it|test)(\.(only|skip|todo))?[[:space:]]*\(/ {
|
|
204
|
+
flush(); open = 1; startline = NR; next
|
|
205
|
+
}
|
|
206
|
+
open && /expect[[:space:]]*\(/ {
|
|
207
|
+
nassert++
|
|
208
|
+
# A "strong" assertion inspects a concrete value: not toBeDefined/toBeTruthy/
|
|
209
|
+
# toBeFalsy/toBeNull/toBeUndefined, and not a bare toHaveBeenCalled().
|
|
210
|
+
if ($0 !~ /\.(toBeDefined|toBeTruthy|toBeFalsy|toBeNull|toBeUndefined|toBeInstanceOf)[[:space:]]*\(/ \
|
|
211
|
+
&& $0 !~ /\.toHaveBeenCalled[[:space:]]*\([[:space:]]*\)/) {
|
|
212
|
+
nstrong++
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
open && /^\s*\}\);?\s*$/ { flush() }
|
|
216
|
+
END { flush() }
|
|
217
|
+
'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Anti-pattern 6 exemption: `@assertion-justified: <reason>`, mirroring
|
|
221
|
+
# `@mock-justified`. The reason must be at least 10 non-space characters -- an
|
|
222
|
+
# annotation without a reason is not an exemption, which is what stops the escape
|
|
223
|
+
# hatch from becoming the default.
|
|
224
|
+
#
|
|
225
|
+
# $1 = the source text to inspect.
|
|
226
|
+
is_assertion_justified() {
|
|
227
|
+
printf '%s\n' "$1" \
|
|
228
|
+
| grep -oE '@assertion-justified:[[:space:]]*[^[:space:]*].*' \
|
|
229
|
+
| sed 's/^@assertion-justified:[[:space:]]*//' \
|
|
230
|
+
| tr -d '\r' \
|
|
231
|
+
| awk 'length($0) >= 10 { found = 1; exit } END { exit(found ? 0 : 1) }'
|
|
232
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Changed-line coverage for Gate 5 (#600).
|
|
3
|
+
#
|
|
4
|
+
# Gate 5 compares GLOBAL coverage against a floor (default 80, hard floor 60).
|
|
5
|
+
# A commit that changes five lines and tests none of them passes as long as the
|
|
6
|
+
# global number has not dipped below that floor -- so the only thing the gate ever
|
|
7
|
+
# measured about a change was the average of everything else in the repo. 指南
|
|
8
|
+
# §10.1/§10.3 asks for three measures the repo never computed: changed-line
|
|
9
|
+
# coverage (>=90%), changed-branch coverage (>=85%), and module coverage that does
|
|
10
|
+
# not go DOWN.
|
|
11
|
+
#
|
|
12
|
+
# This library reads vitest's v8 `coverage/coverage-final.json` (already produced;
|
|
13
|
+
# the json reporter is on) plus the staged diff, and answers exactly one question
|
|
14
|
+
# per changed file: of the lines this commit touched, how many did the tests
|
|
15
|
+
# execute? Staged-only is deliberate -- the question is about the commit, and
|
|
16
|
+
# scanning the working tree would report on code that is not being committed.
|
|
17
|
+
#
|
|
18
|
+
# Fail-closed on unmeasurable inputs (#570): no coverage file, unparseable JSON, or
|
|
19
|
+
# a changed file absent from the coverage report all mean "not measured", and "not
|
|
20
|
+
# measured" must never render as "passed". The one legitimate absence is a file
|
|
21
|
+
# the coverage provider deliberately omits for being non-executable (type-only
|
|
22
|
+
# `.d.ts`, or a source with no statements at all) -- that is reported as `n/a`,
|
|
23
|
+
# not as 0%, because scoring it 0% would block every constants module.
|
|
24
|
+
#
|
|
25
|
+
# The whole computation lives in one node program rather than shell+awk because
|
|
26
|
+
# coverage-final.json is a nested object keyed by absolute path; parsing it in
|
|
27
|
+
# shell means three places to get the path normalisation wrong, and this repo has
|
|
28
|
+
# already paid for that mistake twice (#632, #633).
|
|
29
|
+
|
|
30
|
+
# Report one line per changed file: `<file> <covered> <total> <pct> <verdict>`.
|
|
31
|
+
#
|
|
32
|
+
# $1 = coverage-final.json path
|
|
33
|
+
# $2 = file with changed line ranges, one per line: `<path> <start> <end>`
|
|
34
|
+
# $3 = line-coverage threshold (default 90)
|
|
35
|
+
# $4 = branch-coverage threshold (default 85)
|
|
36
|
+
#
|
|
37
|
+
# Prints:
|
|
38
|
+
# RESULT <covered> <total> <pct> aggregate over all changed lines
|
|
39
|
+
# MISSING <file> changed file with no coverage entry
|
|
40
|
+
# UNCOVERED <file> <line> [<line>...] individual uncovered changed lines
|
|
41
|
+
# BRANCH <covered> <total> <pct> aggregate over changed branches
|
|
42
|
+
# NA <file> nothing executable changed
|
|
43
|
+
# and exits 0 when the aggregate meets both thresholds, 1 otherwise, 2 when the
|
|
44
|
+
# input could not be read at all (fail-closed).
|
|
45
|
+
compute_diff_coverage() {
|
|
46
|
+
local coverage_file="$1" changes_file="$2"
|
|
47
|
+
local line_threshold="${3:-90}" branch_threshold="${4:-85}"
|
|
48
|
+
|
|
49
|
+
if [ ! -f "$coverage_file" ]; then
|
|
50
|
+
echo "ERROR no_coverage_data"
|
|
51
|
+
return 2
|
|
52
|
+
fi
|
|
53
|
+
if [ ! -f "$changes_file" ]; then
|
|
54
|
+
echo "ERROR no_changes"
|
|
55
|
+
return 2
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
node -e '
|
|
59
|
+
const fs = require("fs");
|
|
60
|
+
const [covPath, chgPath, lineT, branchT] = process.argv.slice(1);
|
|
61
|
+
const lineThreshold = Number(lineT);
|
|
62
|
+
const branchThreshold = Number(branchT);
|
|
63
|
+
|
|
64
|
+
let cov;
|
|
65
|
+
try {
|
|
66
|
+
cov = JSON.parse(fs.readFileSync(covPath, "utf8"));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
console.log("ERROR unparsable_coverage");
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Coverage keys are absolute paths emitted by the reporter; the diff paths are
|
|
73
|
+
// repo-relative with forward slashes. Reduce both to a comparable suffix so a
|
|
74
|
+
// Windows drive letter or a leading "./" cannot make every file "missing".
|
|
75
|
+
const norm = (p) => String(p).replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
|
76
|
+
const bySuffix = new Map();
|
|
77
|
+
for (const [absPath, entry] of Object.entries(cov)) {
|
|
78
|
+
const n = norm(absPath);
|
|
79
|
+
bySuffix.set(n, entry);
|
|
80
|
+
// Also index by the trailing two segments so a repo-relative "src/x.ts"
|
|
81
|
+
// can find "/abs/root/src/x.ts" without guessing the root.
|
|
82
|
+
const segs = n.split("/");
|
|
83
|
+
if (segs.length >= 2) bySuffix.set(segs.slice(-2).join("/"), entry);
|
|
84
|
+
}
|
|
85
|
+
const find = (p) => bySuffix.get(norm(p)) || bySuffix.get(norm(p).split("/").slice(-2).join("/"));
|
|
86
|
+
|
|
87
|
+
// Changed line ranges, keyed by file.
|
|
88
|
+
const wanted = new Map();
|
|
89
|
+
for (const raw of fs.readFileSync(chgPath, "utf8").split(/\r?\n/)) {
|
|
90
|
+
const line = raw.trim();
|
|
91
|
+
if (!line) continue;
|
|
92
|
+
const parts = line.split(/\s+/);
|
|
93
|
+
if (parts.length !== 3) continue;
|
|
94
|
+
const [file, s, e] = parts;
|
|
95
|
+
const start = Number(s);
|
|
96
|
+
const end = Number(e);
|
|
97
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) continue;
|
|
98
|
+
if (!wanted.has(file)) wanted.set(file, []);
|
|
99
|
+
wanted.get(file).push([start, end]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let covLines = 0, totLines = 0, covBranches = 0, totBranches = 0;
|
|
103
|
+
for (const [file, ranges] of wanted) {
|
|
104
|
+
const entry = find(file);
|
|
105
|
+
if (!entry) { console.log("MISSING " + file); continue; }
|
|
106
|
+
|
|
107
|
+
// statementMap/s and branchMap/b are OBJECTS keyed by numeric string in the
|
|
108
|
+
// v8 provider this repo uses (they are arrays under the babel provider).
|
|
109
|
+
// Iterate whichever shape is present rather than assuming one.
|
|
110
|
+
const stmts = Array.isArray(entry.statementMap)
|
|
111
|
+
? entry.statementMap.map((v, i) => [String(i), v])
|
|
112
|
+
: Object.entries(entry.statementMap || {});
|
|
113
|
+
const stmtHits = entry.s || {};
|
|
114
|
+
const brs = Array.isArray(entry.branchMap)
|
|
115
|
+
? entry.branchMap.map((v, i) => [String(i), v])
|
|
116
|
+
: Object.entries(entry.branchMap || {});
|
|
117
|
+
const brHits = entry.b || {};
|
|
118
|
+
|
|
119
|
+
const inRange = (ln) => ranges.some(([s, e]) => ln >= s && ln <= e);
|
|
120
|
+
let fileTotal = 0, fileCov = 0;
|
|
121
|
+
const uncovered = [];
|
|
122
|
+
for (const [id, meta] of stmts) {
|
|
123
|
+
const ln = meta && meta.start ? meta.start.line : (meta && meta.line);
|
|
124
|
+
if (!ln || !inRange(ln)) continue;
|
|
125
|
+
fileTotal++;
|
|
126
|
+
if (Number(stmtHits[id] || 0) > 0) fileCov++;
|
|
127
|
+
else uncovered.push(ln);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let fileBTotal = 0, fileBCov = 0;
|
|
131
|
+
for (const [id, meta] of brs) {
|
|
132
|
+
const ln = meta && meta.line ? meta.line : (meta && meta.loc && meta.loc.start ? meta.loc.start.line : 0);
|
|
133
|
+
if (!ln || !inRange(ln)) continue;
|
|
134
|
+
const counts = brHits[id];
|
|
135
|
+
const arr = Array.isArray(counts) ? counts : [counts || 0];
|
|
136
|
+
for (const c of arr) {
|
|
137
|
+
fileBTotal++;
|
|
138
|
+
if (Number(c) > 0) fileBCov++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
covLines += fileCov; totLines += fileTotal;
|
|
143
|
+
covBranches += fileBCov; totBranches += fileBTotal;
|
|
144
|
+
|
|
145
|
+
if (fileTotal === 0 && fileBTotal === 0) { console.log("NA " + file); continue; }
|
|
146
|
+
if (uncovered.length) {
|
|
147
|
+
console.log("UNCOVERED " + file + " " + uncovered.join(" "));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const pct = (c, t) => (t === 0 ? 100 : Math.round((c / t) * 1000) / 10);
|
|
152
|
+
console.log("RESULT " + covLines + " " + totLines + " " + pct(covLines, totLines));
|
|
153
|
+
console.log("BRANCH " + covBranches + " " + totBranches + " " + pct(covBranches, totBranches));
|
|
154
|
+
|
|
155
|
+
// Nothing executable changed at all: not a failure, and not a pass either --
|
|
156
|
+
// the caller decides (docs-only commits SKIP upstream).
|
|
157
|
+
if (totLines === 0 && totBranches === 0) process.exit(0);
|
|
158
|
+
const lineOk = totLines === 0 || pct(covLines, totLines) >= lineThreshold;
|
|
159
|
+
const branchOk = totBranches === 0 || pct(covBranches, totBranches) >= branchThreshold;
|
|
160
|
+
process.exit(lineOk && branchOk ? 0 : 1);
|
|
161
|
+
' "$coverage_file" "$changes_file" "$line_threshold" "$branch_threshold"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Emit `<path> <start> <end>` triples for every added line range in the STAGED diff.
|
|
165
|
+
#
|
|
166
|
+
# Only files the coverage provider can measure are worth listing: a docs-only or
|
|
167
|
+
# config-only diff yields no rows, which makes the caller's "nothing executable
|
|
168
|
+
# changed" branch reachable instead of comparing 0/0.
|
|
169
|
+
#
|
|
170
|
+
# $1 = optional output file (defaults to stdout).
|
|
171
|
+
staged_added_line_ranges() {
|
|
172
|
+
# Prints `<path> <start> <end>` to STDOUT; the caller redirects. Deliberately not
|
|
173
|
+
# "print to $1": awk's `> file` redirect is not portable across the awk builds on
|
|
174
|
+
# this project's target platforms (the Windows Git Bash awk silently wrote
|
|
175
|
+
# nothing), and a range extractor that yields an empty list makes every diff
|
|
176
|
+
# coverage result vacuously 100%.
|
|
177
|
+
#
|
|
178
|
+
# --unified=0 gives one `@@ -a,b +c,d @@` header per contiguous change, so the
|
|
179
|
+
# added range is exactly +c,d and no context lines need filtering. A header with
|
|
180
|
+
# no ",d" means a single-line change.
|
|
181
|
+
git diff --cached --unified=0 --no-color --diff-filter=ACM 2>/dev/null \
|
|
182
|
+
| awk '
|
|
183
|
+
/^\+\+\+ / {
|
|
184
|
+
path = $2
|
|
185
|
+
sub(/^b\//, "", path)
|
|
186
|
+
next
|
|
187
|
+
}
|
|
188
|
+
/^@@ / {
|
|
189
|
+
if (path == "" || path == "/dev/null") next
|
|
190
|
+
if (path !~ /\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|cs|scala|sh|bats|ps1)$/) next
|
|
191
|
+
line = $0
|
|
192
|
+
sub(/^@@ [^+]*\+/, "", line)
|
|
193
|
+
sub(/[^0-9,].*$/, "", line)
|
|
194
|
+
n = split(line, parts, ",")
|
|
195
|
+
start = parts[1] + 0
|
|
196
|
+
len = (n > 1 ? parts[2] + 0 : 1)
|
|
197
|
+
print path " " start " " (start + len - 1)
|
|
198
|
+
next
|
|
199
|
+
}
|
|
200
|
+
'
|
|
201
|
+
return 0
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Read a numeric threshold from .xp-gate/test-profile.yaml, falling back to the
|
|
205
|
+
# documented default (指南 §10.1: 90% lines, 85% branches) when the file or key is
|
|
206
|
+
# absent. Kept here, next to its only consumer, so the two can never drift.
|
|
207
|
+
#
|
|
208
|
+
# $1 = key name (diff_coverage_lines | diff_coverage_branches)
|
|
209
|
+
# $2 = default
|
|
210
|
+
diff_coverage_threshold() {
|
|
211
|
+
local key="$1" fallback="$2" profile=".xp-gate/test-profile.yaml"
|
|
212
|
+
if [ ! -f "$profile" ]; then
|
|
213
|
+
printf '%s' "$fallback"
|
|
214
|
+
return 0
|
|
215
|
+
fi
|
|
216
|
+
local value
|
|
217
|
+
value=$(grep -E "^[[:space:]]*${key}[[:space:]]*:" "$profile" 2>/dev/null \
|
|
218
|
+
| head -1 | sed 's/^[^:]*:[[:space:]]*//' | tr -d '"'"'"' \r')
|
|
219
|
+
case "$value" in
|
|
220
|
+
''|*[!0-9.]*) printf '%s' "$fallback" ;;
|
|
221
|
+
*) printf '%s' "$value" ;;
|
|
222
|
+
esac
|
|
223
|
+
}
|
package/hooks/pre-commit
CHANGED
|
@@ -402,6 +402,15 @@ source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/docs-only.sh"
|
|
|
402
402
|
# so the fail-closed contract is unit-testable without executing this hook.
|
|
403
403
|
source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/test-failure.sh"
|
|
404
404
|
|
|
405
|
+
# Issue #602: test-suite hygiene detectors (.only / .skip / deleted test files),
|
|
406
|
+
# sourced so the same vocabulary is unit-testable and reusable by Gate 5d (#601).
|
|
407
|
+
source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/assertion-quality.sh"
|
|
408
|
+
|
|
409
|
+
# Issue #600: changed-line coverage (指南 §10.1/§10.3), sourced for the same reason --
|
|
410
|
+
# a shell-only parse of coverage-final.json would be a third place to get Windows
|
|
411
|
+
# path normalisation wrong, and this repo has paid for that twice (#632, #633).
|
|
412
|
+
source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/diff-coverage.sh"
|
|
413
|
+
|
|
405
414
|
# Issue #477: Gate 2's jscpd config probe and outcome classification, same reason.
|
|
406
415
|
source_gate_lib "${AUDIT_SCRIPT_DIR}/lib/jscpd-run.sh"
|
|
407
416
|
|
|
@@ -2228,6 +2237,121 @@ if [ "$PROJECT_LANG" = "documentation-only" ] || [ "$DOCS_ONLY_COMMIT" = true ];
|
|
|
2228
2237
|
GATE_5_DETAIL="docs_only_change"
|
|
2229
2238
|
|
|
2230
2239
|
else
|
|
2240
|
+
# ========================================================================
|
|
2241
|
+
# Gate 5-FOCUS / Gate 5-DELETE (#602): the two quietest ways to "fix" a red
|
|
2242
|
+
# build are to commit a `.only` (CI then runs one case and reports green) and
|
|
2243
|
+
# to delete a test file with no replacement (the safety net vanishes with no
|
|
2244
|
+
# signal). 指南 §9.5/§12 forbid both. Gate 5a guards the ADD side -- "a new
|
|
2245
|
+
# source file must have a test" -- so guarding only that side left the
|
|
2246
|
+
# constraint asymmetric: tests could be silently removed but never silently
|
|
2247
|
+
# added, which is backwards from what protects the codebase.
|
|
2248
|
+
#
|
|
2249
|
+
# Runs before the expensive Gate 5a machinery so a `.only` commit fails in
|
|
2250
|
+
# milliseconds instead of after a full test run.
|
|
2251
|
+
# ========================================================================
|
|
2252
|
+
GATE_5_FOCUS_BLOCKED=0
|
|
2253
|
+
GATE_5_SKIP_COUNT=0
|
|
2254
|
+
GATE_5_DELETED_TESTS=""
|
|
2255
|
+
if declare -f classify_test_focus >/dev/null 2>&1; then
|
|
2256
|
+
STAGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
2257
|
+
| grep -E '\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|cs|scala|ps1)$|(^|/)(test_[^/]*\.py|[^/]*_test\.(py|go))$' || true)
|
|
2258
|
+
if [ -n "$STAGED_TEST_FILES" ]; then
|
|
2259
|
+
while IFS= read -r _tf; do
|
|
2260
|
+
[ -n "$_tf" ] || continue
|
|
2261
|
+
_tf_content=$(git show ":$tf" 2>/dev/null) || _tf_content=$(cat "$_tf" 2>/dev/null || true)
|
|
2262
|
+
[ -n "$_tf_content" ] || continue
|
|
2263
|
+
|
|
2264
|
+
# --- focus markers: BLOCK ---
|
|
2265
|
+
_focus_hits=$(printf '%s\n' "$_tf_content" | classify_test_focus || true)
|
|
2266
|
+
if [ -n "$_focus_hits" ]; then
|
|
2267
|
+
# XP_GATE_ALLOW_ONLY=1 is an explicit, audited override for the one
|
|
2268
|
+
# legitimate case (debugging an unrelated failure mid-bisect). It is
|
|
2269
|
+
# opt-in per run and printed, never silent.
|
|
2270
|
+
if [ "${XP_GATE_ALLOW_ONLY:-0}" = "1" ]; then
|
|
2271
|
+
echo "⚠️ WARN - focus marker in $_tf (allowed by XP_GATE_ALLOW_ONLY=1)"
|
|
2272
|
+
record_gate_audit "gate-5-focus-override" "focus-override" "WARN" 1 "$GATE_5_START" "file=$_tf"
|
|
2273
|
+
else
|
|
2274
|
+
echo "❌ BLOCKED - focus marker(s) in $_tf:"
|
|
2275
|
+
printf '%s\n' "$_focus_hits" | sed 's/^/ /'
|
|
2276
|
+
GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
|
|
2277
|
+
fi
|
|
2278
|
+
fi
|
|
2279
|
+
|
|
2280
|
+
# --- skip markers: WARN unless justified ---
|
|
2281
|
+
_skip_hits=$(printf '%s\n' "$_tf_content" | classify_test_skip || true)
|
|
2282
|
+
if [ -n "$_skip_hits" ]; then
|
|
2283
|
+
if is_skip_justified "$_tf_content"; then
|
|
2284
|
+
echo "ℹ️ Skipped case(s) in $_tf are justified via @skip-justified."
|
|
2285
|
+
else
|
|
2286
|
+
_skip_n=$(printf '%s\n' "$_skip_hits" | wc -l | tr -d ' ')
|
|
2287
|
+
GATE_5_SKIP_COUNT=$((GATE_5_SKIP_COUNT + _skip_n))
|
|
2288
|
+
echo "⚠️ WARN - $_skip_n skipped case(s) in $_tf without @skip-justified: <reason>"
|
|
2289
|
+
fi
|
|
2290
|
+
fi
|
|
2291
|
+
# --- Gate 5d (#601): assertion quality ---
|
|
2292
|
+
# 指南 §9.2 forbids six assertion anti-patterns, and until now nothing in
|
|
2293
|
+
# XP-Gate looked at assertions at all: Gate M2/M3 examine how test doubles
|
|
2294
|
+
# are built, never whether the test asserts anything. Tautologies and
|
|
2295
|
+
# empty catches are BLOCK (certainly vacuous); weak-only / mock-echo /
|
|
2296
|
+
# call-order shapes WARN, because each can be legitimate and blocking a
|
|
2297
|
+
# legal style trains people to add justification annotations until the
|
|
2298
|
+
# annotation stops meaning anything.
|
|
2299
|
+
if declare -f classify_tautological_assertions >/dev/null 2>&1; then
|
|
2300
|
+
if is_assertion_justified "$_tf_content"; then
|
|
2301
|
+
echo "ℹ️ Assertion style in $_tf is justified via @assertion-justified."
|
|
2302
|
+
else
|
|
2303
|
+
_tauto=$(printf '%s\n' "$_tf_content" | classify_tautological_assertions || true)
|
|
2304
|
+
_swallow=$(printf '%s\n' "$_tf_content" | classify_swallowed_errors || true)
|
|
2305
|
+
_weak=$(printf '%s\n' "$_tf_content" | classify_weak_assertions || true)
|
|
2306
|
+
if [ -n "$_tauto" ]; then
|
|
2307
|
+
echo "❌ BLOCKED - tautological assertion(s) in $_tf (cannot fail):"
|
|
2308
|
+
printf '%s\n' "$_tauto" | sed 's/^/ /'
|
|
2309
|
+
echo " See skills/test-driven-development/testing-anti-patterns.md"
|
|
2310
|
+
GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
|
|
2311
|
+
fi
|
|
2312
|
+
if [ -n "$_swallow" ]; then
|
|
2313
|
+
echo "❌ BLOCKED - empty catch in $_tf swallows the failure it guards:"
|
|
2314
|
+
printf '%s\n' "$_swallow" | sed 's/^/ /'
|
|
2315
|
+
echo " See skills/test-driven-development/testing-anti-patterns.md"
|
|
2316
|
+
GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
|
|
2317
|
+
fi
|
|
2318
|
+
if [ -n "$_weak" ]; then
|
|
2319
|
+
echo "⚠️ WARN - test block(s) in $_tf assert only weak properties:"
|
|
2320
|
+
printf '%s\n' "$_weak" | sed 's/^/ /'
|
|
2321
|
+
echo " toBeDefined/toBeTruthy alone cannot detect a wrong value."
|
|
2322
|
+
GATE_5_SKIP_COUNT=$((GATE_5_SKIP_COUNT + $(printf '%s\n' "$_weak" | wc -l | tr -d ' ')))
|
|
2323
|
+
fi
|
|
2324
|
+
fi
|
|
2325
|
+
fi
|
|
2326
|
+
done <<< "$STAGED_TEST_FILES"
|
|
2327
|
+
|
|
2328
|
+
# --- deleted test files: WARN ---
|
|
2329
|
+
DELETED_LIST=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/xp-deleted-$$")
|
|
2330
|
+
ADDED_LIST=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/xp-added-$$")
|
|
2331
|
+
git diff --cached --name-only --diff-filter=D > "$DELETED_LIST" 2>/dev/null || true
|
|
2332
|
+
git diff --cached --name-only --diff-filter=A > "$ADDED_LIST" 2>/dev/null || true
|
|
2333
|
+
GATE_5_DELETED_TESTS=$(classify_deleted_tests "$DELETED_LIST" "$ADDED_LIST" || true)
|
|
2334
|
+
rm -f "$DELETED_LIST" "$ADDED_LIST"
|
|
2335
|
+
if [ -n "$GATE_5_DELETED_TESTS" ]; then
|
|
2336
|
+
echo "⚠️ WARN - test file(s) deleted with no same-named replacement:"
|
|
2337
|
+
printf '%s\n' "$GATE_5_DELETED_TESTS" | sed 's/^/ /'
|
|
2338
|
+
echo " Deleting a test removes the safety net (指南 §9.5). If the code is"
|
|
2339
|
+
echo " genuinely gone, say so in the commit body; otherwise restore it."
|
|
2340
|
+
fi
|
|
2341
|
+
fi
|
|
2342
|
+
fi
|
|
2343
|
+
|
|
2344
|
+
if [ "$GATE_5_FOCUS_BLOCKED" -gt 0 ]; then
|
|
2345
|
+
echo ""
|
|
2346
|
+
echo "❌ BLOCKED - Gate 5: $GATE_5_FOCUS_BLOCKED test-hygiene violation(s) (see above)."
|
|
2347
|
+
echo " Focus markers (.only/fit/fdescribe) make CI run one case and report green;"
|
|
2348
|
+
echo " tautological assertions and empty catches verify nothing at all."
|
|
2349
|
+
echo " See skills/test-driven-development/testing-anti-patterns.md"
|
|
2350
|
+
echo " Remove them, or add '@assertion-justified: <reason>' for a reviewed exception."
|
|
2351
|
+
record_gate_audit "gate-5-focus" "test-focus" "FAIL" "$GATE_5_FOCUS_BLOCKED" "$GATE_5_START" "blocked=1"
|
|
2352
|
+
exit 1
|
|
2353
|
+
fi
|
|
2354
|
+
|
|
2231
2355
|
# ========================================================================
|
|
2232
2356
|
# Gate 5a: Test-Source File Pairing Check
|
|
2233
2357
|
# New .ts/.tsx files: BLOCK (no corresponding test → commit rejected)
|
|
@@ -3086,6 +3210,57 @@ else
|
|
|
3086
3210
|
else
|
|
3087
3211
|
echo "TypeScript coverage: ${COVERAGE_PERCENT}% ✅ (≥ ${COVERAGE_THRESHOLD:-80}%)"
|
|
3088
3212
|
fi
|
|
3213
|
+
|
|
3214
|
+
# ---- Diff coverage (#600): 指南 §10.1/§10.3 ----
|
|
3215
|
+
# The block above measures the WHOLE repo. A commit that changes five
|
|
3216
|
+
# lines and tests none of them passes as long as the global average has
|
|
3217
|
+
# not dipped -- so the only thing that gate ever knew about THIS change
|
|
3218
|
+
# was the average of everything else. This block asks the narrower and
|
|
3219
|
+
# more useful question: of the lines this commit touches, how many did
|
|
3220
|
+
# the tests execute?
|
|
3221
|
+
#
|
|
3222
|
+
# Skipped on a subset run, like the global check above: a partial run
|
|
3223
|
+
# legitimately leaves most changed lines unexecuted because the tests
|
|
3224
|
+
# covering them were deselected, and reporting that as low coverage
|
|
3225
|
+
# would punish the smart-selection feature for working.
|
|
3226
|
+
if declare -f compute_diff_coverage >/dev/null 2>&1 \
|
|
3227
|
+
&& [ "${PARTIAL_TEST_RUN:-false}" != "true" ] \
|
|
3228
|
+
&& [ -f "coverage/coverage-final.json" ]; then
|
|
3229
|
+
DIFF_COV_RANGES=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/diffcov-$$")
|
|
3230
|
+
DIFF_COV_LINE_T=$(diff_coverage_threshold diff_coverage_lines 90)
|
|
3231
|
+
DIFF_COV_BRANCH_T=$(diff_coverage_threshold diff_coverage_branches 85)
|
|
3232
|
+
staged_added_line_ranges > "$DIFF_COV_RANGES" 2>/dev/null || true
|
|
3233
|
+
if [ -s "$DIFF_COV_RANGES" ]; then
|
|
3234
|
+
DIFF_COV_OUT=$(compute_diff_coverage coverage/coverage-final.json \
|
|
3235
|
+
"$DIFF_COV_RANGES" "$DIFF_COV_LINE_T" "$DIFF_COV_BRANCH_T" 2>&1)
|
|
3236
|
+
DIFF_COV_RC=$?
|
|
3237
|
+
DIFF_COV_RESULT=$(printf '%s\n' "$DIFF_COV_OUT" | grep '^RESULT ' | head -1)
|
|
3238
|
+
DIFF_COV_BRANCH=$(printf '%s\n' "$DIFF_COV_OUT" | grep '^BRANCH ' | head -1)
|
|
3239
|
+
if [ "$DIFF_COV_RC" -eq 0 ]; then
|
|
3240
|
+
echo "Changed-line coverage: ${DIFF_COV_RESULT#RESULT }% ✅ (lines, ≥ ${DIFF_COV_LINE_T}%)"
|
|
3241
|
+
if printf '%s\n' "$DIFF_COV_OUT" | grep -q '^NA '; then
|
|
3242
|
+
printf '%s\n' "$DIFF_COV_OUT" | grep '^NA ' | sed 's/^NA / n\/a (no executable statements): /'
|
|
3243
|
+
fi
|
|
3244
|
+
elif [ "$DIFF_COV_RC" -eq 1 ]; then
|
|
3245
|
+
echo "❌ BLOCKED - Changed-line coverage below threshold (指南 §10.1)."
|
|
3246
|
+
echo " Lines: ${DIFF_COV_RESULT#RESULT } (need ≥ ${DIFF_COV_LINE_T}%)"
|
|
3247
|
+
echo " Branches: ${DIFF_COV_BRANCH#BRANCH } (need ≥ ${DIFF_COV_BRANCH_T}%)"
|
|
3248
|
+
printf '%s\n' "$DIFF_COV_OUT" | grep '^UNCOVERED ' | sed 's/^UNCOVERED / untested changed lines: /'
|
|
3249
|
+
printf '%s\n' "$DIFF_COV_OUT" | grep '^MISSING ' | sed 's/^MISSING / no coverage data for: /'
|
|
3250
|
+
echo " Add tests for the lines listed above, or set diff_coverage_lines in"
|
|
3251
|
+
echo " .xp-gate/test-profile.yaml if this file genuinely cannot be unit-tested."
|
|
3252
|
+
rm -f "$DIFF_COV_RANGES"
|
|
3253
|
+
exit 1
|
|
3254
|
+
else
|
|
3255
|
+
# #570: not measurable is not a pass. (The global check above already
|
|
3256
|
+
# blocked on a missing/corrupt report; this arm covers the narrower
|
|
3257
|
+
# case where coverage-final.json itself is unreadable.)
|
|
3258
|
+
echo "⚠️ Changed-line coverage could not be measured (coverage-final.json unreadable)."
|
|
3259
|
+
echo " See: $(printf '%s\n' "$DIFF_COV_OUT" | grep '^ERROR ' | head -1)"
|
|
3260
|
+
fi
|
|
3261
|
+
fi
|
|
3262
|
+
rm -f "$DIFF_COV_RANGES"
|
|
3263
|
+
fi
|
|
3089
3264
|
else
|
|
3090
3265
|
# #570: an unparseable report is not a pass. Silently continuing here
|
|
3091
3266
|
# meant a corrupt or unexpected-shape coverage-summary.json disabled the
|