@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.
Files changed (42) hide show
  1. package/hooks/lib/assertion-quality.sh +232 -0
  2. package/hooks/lib/diff-coverage.sh +223 -0
  3. package/hooks/pre-commit +175 -0
  4. package/lib/mattpocock-license-verdict.json +1 -1
  5. package/mock-policy/AGENTS.md +2 -2
  6. package/mutation/AGENTS.md +2 -2
  7. package/package.json +1 -1
  8. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  9. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  10. package/plugins/claude-code/skills/delphi-review/SKILL.md +30 -0
  11. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  12. package/plugins/claude-code/skills/test-driven-development/SKILL.md +19 -0
  13. package/plugins/claude-code/skills/test-driven-development/testing-anti-patterns.md +31 -0
  14. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  15. package/plugins/dsh/package.json +1 -1
  16. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  17. package/plugins/dsh/skills/delphi-review/SKILL.md +30 -0
  18. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  19. package/plugins/dsh/skills/test-driven-development/SKILL.md +19 -0
  20. package/plugins/dsh/skills/test-driven-development/testing-anti-patterns.md +31 -0
  21. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  22. package/plugins/opencode/package.json +1 -1
  23. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/opencode/skills/delphi-review/SKILL.md +30 -0
  25. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  26. package/plugins/opencode/skills/test-driven-development/SKILL.md +19 -0
  27. package/plugins/opencode/skills/test-driven-development/testing-anti-patterns.md +31 -0
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/qoder/skills/delphi-review/SKILL.md +30 -0
  32. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  33. package/plugins/qoder/skills/test-driven-development/SKILL.md +19 -0
  34. package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +31 -0
  35. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/principles/AGENTS.md +2 -2
  37. package/skills/delphi-review/AGENTS.md +2 -2
  38. package/skills/delphi-review/SKILL.md +30 -0
  39. package/skills/sprint-flow/AGENTS.md +2 -2
  40. package/skills/test-driven-development/SKILL.md +19 -0
  41. package/skills/test-driven-development/testing-anti-patterns.md +31 -0
  42. 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
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-30T13:23:44.380Z",
5
+ "checked_at": "2026-10-30T14:08:10.171Z",
6
6
  "source": "network"
7
7
  }