@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.
Files changed (51) hide show
  1. package/gate-13-compiler-strictness.sh +129 -4
  2. package/gate-4.sh +8 -1
  3. package/hooks/gate-13-compiler-strictness.sh +129 -4
  4. package/hooks/gate-4.sh +8 -1
  5. package/hooks/lib/assertion-quality.sh +232 -0
  6. package/hooks/lib/diff-coverage.sh +223 -0
  7. package/hooks/pre-commit +175 -0
  8. package/lib/__tests__/foreign-hooks-global.test.js +125 -0
  9. package/lib/__tests__/uninstall-global-residue.test.js +160 -0
  10. package/lib/doctor.js +70 -0
  11. package/lib/mattpocock-license-verdict.json +1 -1
  12. package/lib/uninstall.js +225 -1
  13. package/mock-policy/AGENTS.md +2 -2
  14. package/mutation/AGENTS.md +2 -2
  15. package/package.json +1 -1
  16. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  17. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  18. package/plugins/claude-code/skills/delphi-review/SKILL.md +30 -0
  19. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  20. package/plugins/claude-code/skills/test-driven-development/SKILL.md +19 -0
  21. package/plugins/claude-code/skills/test-driven-development/testing-anti-patterns.md +31 -0
  22. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  23. package/plugins/dsh/package.json +1 -1
  24. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  25. package/plugins/dsh/skills/delphi-review/SKILL.md +30 -0
  26. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  27. package/plugins/dsh/skills/test-driven-development/SKILL.md +19 -0
  28. package/plugins/dsh/skills/test-driven-development/testing-anti-patterns.md +31 -0
  29. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  30. package/plugins/opencode/package.json +1 -1
  31. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  32. package/plugins/opencode/skills/delphi-review/SKILL.md +30 -0
  33. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  34. package/plugins/opencode/skills/test-driven-development/SKILL.md +19 -0
  35. package/plugins/opencode/skills/test-driven-development/testing-anti-patterns.md +31 -0
  36. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  37. package/plugins/qoder/plugin.json +1 -1
  38. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  39. package/plugins/qoder/skills/delphi-review/SKILL.md +30 -0
  40. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/test-driven-development/SKILL.md +19 -0
  42. package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +31 -0
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/principles/rules/solid/dip.ts +94 -14
  46. package/skills/delphi-review/AGENTS.md +2 -2
  47. package/skills/delphi-review/SKILL.md +30 -0
  48. package/skills/sprint-flow/AGENTS.md +2 -2
  49. package/skills/test-driven-development/SKILL.md +19 -0
  50. package/skills/test-driven-development/testing-anti-patterns.md +31 -0
  51. 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
+ });