@boyingliu01/xp-gate 0.33.2 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gate-13-compiler-strictness.sh +129 -4
- package/gate-4.sh +8 -1
- package/hooks/gate-13-compiler-strictness.sh +129 -4
- package/hooks/gate-4.sh +8 -1
- package/hooks/lib/assertion-quality.sh +232 -0
- package/hooks/lib/diff-coverage.sh +223 -0
- package/hooks/pre-commit +175 -0
- package/lib/__tests__/foreign-hooks-global.test.js +125 -0
- package/lib/__tests__/uninstall-global-residue.test.js +160 -0
- package/lib/doctor.js +70 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/uninstall.js +225 -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/principles/rules/solid/dip.ts +94 -14
- 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,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
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-664
|
|
3
|
+
* @intent `setup-global` points `core.hooksPath` at `~/.config/xp-gate/hooks`, and
|
|
4
|
+
* that setting is GLOBAL — it governs every repository on the machine. So
|
|
5
|
+
* any third-party tool that writes hooks into `core.hooksPath` writes into
|
|
6
|
+
* a directory xp-gate owns. `git lfs install` does exactly this, and the
|
|
7
|
+
* measured result was four hooks xp-gate never installs appearing there:
|
|
8
|
+
*
|
|
9
|
+
* hook-module-resolution.sh lib/ post-checkout post-commit
|
|
10
|
+
* post-merge pre-commit pre-push
|
|
11
|
+
*
|
|
12
|
+
* Two failures followed:
|
|
13
|
+
*
|
|
14
|
+
* 1. `doctor` only checked pre-commit / pre-push / post-merge. The extra
|
|
15
|
+
* files were neither reported nor explained, so the user had no way to
|
|
16
|
+
* learn that a global install had acquired foreign hooks.
|
|
17
|
+
* 2. `uninstall` did `rmSync(GLOBAL_HOOKS_DIR, {recursive: true})` — the
|
|
18
|
+
* whole tree, including the lfs hooks someone else put there. The
|
|
19
|
+
* file already had `verifyFileOwnership()`; the `type: 'dir'` branch
|
|
20
|
+
* simply did not use it.
|
|
21
|
+
*
|
|
22
|
+
* Deleting another tool's files is the more serious of the two: the user
|
|
23
|
+
* loses a working git-lfs setup and has no idea which command did it. So
|
|
24
|
+
* uninstall must classify before it removes, and SAY what it found.
|
|
25
|
+
* @covers AC-670-40, AC-670-41, AC-670-42
|
|
26
|
+
*/
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
/** Hooks xp-gate itself installs; anything else in the dir is foreign. */
|
|
34
|
+
const XP_GATE_HOOKS = ['pre-commit', 'pre-push', 'post-merge'];
|
|
35
|
+
|
|
36
|
+
function freshRequire(relPath) {
|
|
37
|
+
for (const key of Object.keys(require.cache)) {
|
|
38
|
+
if (key.includes(`${path.sep}npm-package${path.sep}lib${path.sep}`)) {
|
|
39
|
+
delete require.cache[key];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return require(relPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A hooks directory shaped like the measured post-`git lfs install` state. */
|
|
46
|
+
function makeHooksDir() {
|
|
47
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-'));
|
|
48
|
+
for (const hook of XP_GATE_HOOKS) {
|
|
49
|
+
fs.writeFileSync(path.join(dir, hook), '#!/bin/sh\n# xp-gate\n');
|
|
50
|
+
}
|
|
51
|
+
// Foreign: two hooks git-lfs installs, plus its support file.
|
|
52
|
+
fs.writeFileSync(path.join(dir, 'post-checkout'), '#!/bin/sh\ngit lfs post-checkout "$@"\n');
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'post-commit'), '#!/bin/sh\ngit lfs post-commit "$@"\n');
|
|
54
|
+
fs.writeFileSync(path.join(dir, 'hook-module-resolution.sh'), '#!/bin/sh\n');
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('foreign hooks in the global hooksPath (#664)', () => {
|
|
59
|
+
it('AC-670-40: the classifier names xp-gate hooks and flags everything else', () => {
|
|
60
|
+
const { classifyHooksDir } = freshRequire('../doctor');
|
|
61
|
+
expect(typeof classifyHooksDir).toBe('function');
|
|
62
|
+
|
|
63
|
+
const dir = makeHooksDir();
|
|
64
|
+
try {
|
|
65
|
+
const result = classifyHooksDir(dir);
|
|
66
|
+
expect(result.owned.sort()).toEqual([...XP_GATE_HOOKS].sort());
|
|
67
|
+
// Every foreign entry is reported, not silently dropped. `foreign` holds
|
|
68
|
+
// { name, source } records, so compare names.
|
|
69
|
+
const foreignNames = result.foreign.map((f) => f.name);
|
|
70
|
+
expect(foreignNames).toContain('post-checkout');
|
|
71
|
+
expect(foreignNames).toContain('post-commit');
|
|
72
|
+
// lib/ is xp-gate's own shared-library directory, not foreign.
|
|
73
|
+
expect(foreignNames).not.toContain('lib');
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('AC-670-41: git-lfs hooks are identified by source, unknown files stay generic', () => {
|
|
80
|
+
const { classifyHooksDir } = freshRequire('../doctor');
|
|
81
|
+
const dir = makeHooksDir();
|
|
82
|
+
try {
|
|
83
|
+
const result = classifyHooksDir(dir);
|
|
84
|
+
// Naming the likely source is what turns a confusing listing into an
|
|
85
|
+
// actionable message: "git lfs" tells the user which tool to look at.
|
|
86
|
+
const lfs = result.foreign.filter((f) => f.source === 'git-lfs').map((f) => f.name);
|
|
87
|
+
expect(lfs).toContain('post-checkout');
|
|
88
|
+
expect(lfs).toContain('post-commit');
|
|
89
|
+
|
|
90
|
+
// A file with no recognizable signature must still be reported, but without
|
|
91
|
+
// guessing a source it cannot prove.
|
|
92
|
+
fs.writeFileSync(path.join(dir, 'post-rebase'), '#!/bin/sh\necho mine\n');
|
|
93
|
+
const again = classifyHooksDir(dir);
|
|
94
|
+
const unknown = again.foreign.find((f) => f.name === 'post-rebase');
|
|
95
|
+
expect(unknown).toBeDefined();
|
|
96
|
+
expect(unknown.source).not.toBe('git-lfs');
|
|
97
|
+
} finally {
|
|
98
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('AC-670-42: uninstall refuses to delete a hooks dir holding foreign files', () => {
|
|
103
|
+
const { planHooksDirRemoval } = freshRequire('../uninstall');
|
|
104
|
+
expect(typeof planHooksDirRemoval).toBe('function');
|
|
105
|
+
|
|
106
|
+
const dir = makeHooksDir();
|
|
107
|
+
try {
|
|
108
|
+
const plan = planHooksDirRemoval(dir);
|
|
109
|
+
// Recursive removal would take git-lfs's files with it, so it must not be
|
|
110
|
+
// the chosen action while foreign content is present.
|
|
111
|
+
expect(plan.action).not.toBe('remove-all');
|
|
112
|
+
expect(plan.foreign.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
// A directory holding ONLY xp-gate files is safe to remove wholesale.
|
|
115
|
+
const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hooks-clean-'));
|
|
116
|
+
for (const hook of XP_GATE_HOOKS) {
|
|
117
|
+
fs.writeFileSync(path.join(clean, hook), '#!/bin/sh\n# xp-gate\n');
|
|
118
|
+
}
|
|
119
|
+
expect(planHooksDirRemoval(clean).action).toBe('remove-all');
|
|
120
|
+
fs.rmSync(clean, { recursive: true, force: true });
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|