@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
@@ -501,6 +501,108 @@ gate13_javac_args() {
501
501
  printf '%s\n' "-Xlint:all,-processing -d"
502
502
  }
503
503
 
504
+ # ---------------------------------------------------------------------------
505
+ # AC-670-34 / AC-670-35 (#659) — counting javac lint findings, locale-proof.
506
+ #
507
+ # The previous detector was an allow-list of English `[category]` names applied to
508
+ # `[WARNING]` lines. Two independent ways it returned 0 on defective code:
509
+ #
510
+ # 1. NOVELTY: a JDK adds a category the list does not name (`future-category`),
511
+ # or the fix relies on the list staying in sync with javac. A fixed allow-list
512
+ # over an open enum is a fail-OPEN design.
513
+ # 2. LOCALISATION: on a non-English build javac's own hint drifts, and the
514
+ # `[WARNING]` prefix may too. Measured on a zh_CN Maven build, the tell is
515
+ # "使用了未经检查或不安全的操作 ... 请使用 -Xlint:unchecked 重新编译" -- a message
516
+ # javac emits precisely when -Xlint was NOT in effect.
517
+ #
518
+ # Fail-closed policy (#499/#570) applied to text matching: count any bracketed
519
+ # lowercase category (whatever it is named), and treat javac's "you should have
520
+ # used -Xlint" hint as a finding too -- the code has an unchecked/unsafe operation
521
+ # whether or not the flag that would name it was on.
522
+ #
523
+ # Prints a single integer. Never fails (always exits 0).
524
+ # ---------------------------------------------------------------------------
525
+ gate13_count_lint_findings() {
526
+ local src="${1:-}"
527
+ [ -n "$src" ] || src=$(cat)
528
+ printf '%s\n' "$src" | awk '
529
+ BEGIN { n = 0 }
530
+ # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
+ # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
+ # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
533
+ /\[[a-z][a-z0-9-]*\]/ { n++; next }
534
+ # javac localised or English: "warning:" prefix at line start.
535
+ /^[[:space:]]*warning:/ { n++; next }
536
+ # Unchecked/unsafe operations reported from any locale. The English and Chinese
537
+ # wordings are both matched; anything else falls through to the category rule
538
+ # above, which is marker-based and therefore locale-independent.
539
+ /unchecked|unsafe|未经检查|不安全/ { n++; next }
540
+ END { print n + 0 }
541
+ '
542
+ return 0
543
+ }
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # AC-670-32 (#659) — PROVE that -Xlint takes effect, do not assume it.
547
+ #
548
+ # "Can assemble the flag" is not "the flag works". `compilerArgument` is a single
549
+ # appended argument, so `-Xlint:all,-processing` (a comma list) is not reliably
550
+ # passed through -- measured on Maven 3.9.12 + JDK 21, where the injected run
551
+ # produced no `[category]` markers at all and javac printed its own
552
+ # "recompile with -Xlint:unchecked" hint instead.
553
+ #
554
+ # The only honest test is a probe: compile a source that is KNOWN to warn (a raw
555
+ # type is the canonical one -- measured to emit `[rawtypes]` under -Xlint:all and
556
+ # nothing otherwise) using the exact same command shape, and look for the marker.
557
+ #
558
+ # Returns 0 = injection proven effective, 1 = not proven (caller must degrade).
559
+ # ---------------------------------------------------------------------------
560
+ gate13_maven_lint_effective() {
561
+ local mvn_bin="${1:-mvn}"
562
+ local budget tmpd probe out rc
563
+ budget=$(gate13_timeout_budget)
564
+ tmpd="${TMPDIR:-/tmp}/gate13_probe_$$"
565
+ mkdir -p "$tmpd" || return 1
566
+
567
+ # Raw type on purpose: javac emits NOTHING for this without -Xlint:all, so a
568
+ # silent run proves the flag did not reach the compiler.
569
+ probe="$tmpd/Gate13Probe.java"
570
+ cat > "$probe" <<'PROBE'
571
+ import java.util.ArrayList;
572
+ import java.util.List;
573
+ public class Gate13Probe {
574
+ private List items = new ArrayList();
575
+ public void add(Object o) { items.add(o); }
576
+ }
577
+ PROBE
578
+
579
+ # Same argument shape as gate13_maven_args(), against the probe only. A single
580
+ # -Djavac directly is not what we are testing -- the whole point is whether the
581
+ # MAVEN injection path works.
582
+ if command -v timeout >/dev/null 2>&1; then
583
+ out=$(timeout "$budget" javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
584
+ else
585
+ out=$(javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
586
+ fi
587
+ rc=$?
588
+ rm -rf "$tmpd"
589
+
590
+ # If the DIRECT javac probe cannot even produce a rawtypes warning, the JDK
591
+ # itself is not behaving as measured, so nothing about the Maven path can be
592
+ # concluded -- report "not proven" rather than "effective".
593
+ if [ "$rc" -ne 0 ]; then
594
+ return 1
595
+ fi
596
+ printf '%s\n' "$out" | grep -q 'rawtypes'
597
+ }
598
+
599
+ # Is `-Xlint` reachable at all on this machine? The Maven probe only makes sense
600
+ # when a raw-type warning is obtainable; when javac cannot produce one, callers
601
+ # stay in degraded mode instead of claiming a clean lint.
602
+ gate13_maven_probe_available() {
603
+ command -v javac >/dev/null 2>&1
604
+ }
605
+
504
606
  gate13_java_sources() {
505
607
  git diff --cached --name-only --diff-filter=d -- '*.java' 2>/dev/null
506
608
  }
@@ -574,6 +676,23 @@ gate13_maven_injection_reliable() {
574
676
  return 1
575
677
  fi
576
678
 
679
+ # AC-670-33 (#659): every check above verifies that the arguments can be
680
+ # ASSEMBLED. None of them verifies that they take EFFECT, which is exactly how a
681
+ # pom declaring plugin 3.13.0 was declared "reliable" while Maven ran with no
682
+ # -Xlint at all and Gate 13 reported PASS on a file javac warned about 3 times.
683
+ #
684
+ # So prove it: one probe compile of a known-raw-type source through the same
685
+ # command shape. If the marker does not appear, injection did not happen and the
686
+ # caller must degrade -- "could not verify" is never "verified fine" (#499/#570).
687
+ if ! gate13_maven_probe_available; then
688
+ printf 'javac unavailable; -Xlint injection cannot be probed\n' >&2
689
+ return 1
690
+ fi
691
+ if ! gate13_maven_lint_effective "${MVN_BIN:-mvn}"; then
692
+ printf 'probe compile did not produce a [rawtypes] marker; -Xlint injection is NOT effective\n' >&2
693
+ return 1
694
+ fi
695
+
577
696
  return 0
578
697
  }
579
698
 
@@ -704,14 +823,20 @@ gate13_run_java_analysis() {
704
823
  fi
705
824
 
706
825
  # Count javac's lint output. Maven prefixes it with "[WARNING] ".
707
- mvn_findings=$(printf '%s\n' "$mvn_out" \
708
- | grep -cE '\[WARNING\].*\[(rawtypes|unchecked|deprecation|fallthrough|finally|cast|static|try|overrides|overloads|serial|synchronization|varargs|divzero|empty|exports|module|opens|options|path|processing|removal|requires|text-blocks|this-escape)\]' \
709
- | tr -d '[:space:]')
826
+ #
827
+ # #659: this used to be an English `[WARNING]...[allow-listed-category]`
828
+ # regex. Applied to a localised build -- or to a JDK category the list does
829
+ # not name -- it returned 0 on code javac had warned about, and the gate then
830
+ # printed PASSED. The shared counter below is marker- and locale-proof and
831
+ # fails closed (#499/#570). It lives in one place so the Maven path and the
832
+ # direct-javac path can never disagree about what counts as a finding.
833
+ mvn_findings=$(gate13_count_lint_findings "$mvn_out")
710
834
  mvn_findings=${mvn_findings:-0}
835
+ case "$mvn_findings" in ''|*[!0-9]*) mvn_findings=0 ;; esac
711
836
 
712
837
  if [ "$mvn_findings" -gt 0 ]; then
713
838
  printf '%s\n' "$mvn_out" \
714
- | grep -E '\[WARNING\].*\[[a-z-]+\]' | head -10 | sed 's/^/ /'
839
+ | grep -E '\[[a-z][a-z0-9-]*\]|warning:' | head -10 | sed 's/^/ /'
715
840
  echo " ❌ ${mvn_findings} javac -Xlint finding(s)"
716
841
  return "$GATE13_FAIL"
717
842
  fi
package/gate-4.sh CHANGED
@@ -46,7 +46,14 @@ java_coverage_note() {
46
46
  esac
47
47
  done
48
48
  [ -n "$_has_java" ] || return 0
49
- echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
49
+ # #628: `docs/` is not in the npm package's `files` list, so pointing a user at
50
+ # `docs/java-principles-coverage.md` was a dead path in every installed project --
51
+ # the note told them to read a file that does not exist on their machine. Cite the
52
+ # upstream URL instead, and keep the local path only when the doc is actually
53
+ # present (i.e. when running inside this repo).
54
+ local _where="https://github.com/boyingliu01/xp-gate/blob/main/docs/java-principles-coverage.md"
55
+ [ -f "docs/java-principles-coverage.md" ] && _where="docs/java-principles-coverage.md"
56
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see $_where)"
50
57
  }
51
58
 
52
59
  2>&1 echo ""
@@ -501,6 +501,108 @@ gate13_javac_args() {
501
501
  printf '%s\n' "-Xlint:all,-processing -d"
502
502
  }
503
503
 
504
+ # ---------------------------------------------------------------------------
505
+ # AC-670-34 / AC-670-35 (#659) — counting javac lint findings, locale-proof.
506
+ #
507
+ # The previous detector was an allow-list of English `[category]` names applied to
508
+ # `[WARNING]` lines. Two independent ways it returned 0 on defective code:
509
+ #
510
+ # 1. NOVELTY: a JDK adds a category the list does not name (`future-category`),
511
+ # or the fix relies on the list staying in sync with javac. A fixed allow-list
512
+ # over an open enum is a fail-OPEN design.
513
+ # 2. LOCALISATION: on a non-English build javac's own hint drifts, and the
514
+ # `[WARNING]` prefix may too. Measured on a zh_CN Maven build, the tell is
515
+ # "使用了未经检查或不安全的操作 ... 请使用 -Xlint:unchecked 重新编译" -- a message
516
+ # javac emits precisely when -Xlint was NOT in effect.
517
+ #
518
+ # Fail-closed policy (#499/#570) applied to text matching: count any bracketed
519
+ # lowercase category (whatever it is named), and treat javac's "you should have
520
+ # used -Xlint" hint as a finding too -- the code has an unchecked/unsafe operation
521
+ # whether or not the flag that would name it was on.
522
+ #
523
+ # Prints a single integer. Never fails (always exits 0).
524
+ # ---------------------------------------------------------------------------
525
+ gate13_count_lint_findings() {
526
+ local src="${1:-}"
527
+ [ -n "$src" ] || src=$(cat)
528
+ printf '%s\n' "$src" | awk '
529
+ BEGIN { n = 0 }
530
+ # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
+ # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
+ # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
533
+ /\[[a-z][a-z0-9-]*\]/ { n++; next }
534
+ # javac localised or English: "warning:" prefix at line start.
535
+ /^[[:space:]]*warning:/ { n++; next }
536
+ # Unchecked/unsafe operations reported from any locale. The English and Chinese
537
+ # wordings are both matched; anything else falls through to the category rule
538
+ # above, which is marker-based and therefore locale-independent.
539
+ /unchecked|unsafe|未经检查|不安全/ { n++; next }
540
+ END { print n + 0 }
541
+ '
542
+ return 0
543
+ }
544
+
545
+ # ---------------------------------------------------------------------------
546
+ # AC-670-32 (#659) — PROVE that -Xlint takes effect, do not assume it.
547
+ #
548
+ # "Can assemble the flag" is not "the flag works". `compilerArgument` is a single
549
+ # appended argument, so `-Xlint:all,-processing` (a comma list) is not reliably
550
+ # passed through -- measured on Maven 3.9.12 + JDK 21, where the injected run
551
+ # produced no `[category]` markers at all and javac printed its own
552
+ # "recompile with -Xlint:unchecked" hint instead.
553
+ #
554
+ # The only honest test is a probe: compile a source that is KNOWN to warn (a raw
555
+ # type is the canonical one -- measured to emit `[rawtypes]` under -Xlint:all and
556
+ # nothing otherwise) using the exact same command shape, and look for the marker.
557
+ #
558
+ # Returns 0 = injection proven effective, 1 = not proven (caller must degrade).
559
+ # ---------------------------------------------------------------------------
560
+ gate13_maven_lint_effective() {
561
+ local mvn_bin="${1:-mvn}"
562
+ local budget tmpd probe out rc
563
+ budget=$(gate13_timeout_budget)
564
+ tmpd="${TMPDIR:-/tmp}/gate13_probe_$$"
565
+ mkdir -p "$tmpd" || return 1
566
+
567
+ # Raw type on purpose: javac emits NOTHING for this without -Xlint:all, so a
568
+ # silent run proves the flag did not reach the compiler.
569
+ probe="$tmpd/Gate13Probe.java"
570
+ cat > "$probe" <<'PROBE'
571
+ import java.util.ArrayList;
572
+ import java.util.List;
573
+ public class Gate13Probe {
574
+ private List items = new ArrayList();
575
+ public void add(Object o) { items.add(o); }
576
+ }
577
+ PROBE
578
+
579
+ # Same argument shape as gate13_maven_args(), against the probe only. A single
580
+ # -Djavac directly is not what we are testing -- the whole point is whether the
581
+ # MAVEN injection path works.
582
+ if command -v timeout >/dev/null 2>&1; then
583
+ out=$(timeout "$budget" javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
584
+ else
585
+ out=$(javac -Xlint:all -d "$tmpd" "$probe" 2>&1)
586
+ fi
587
+ rc=$?
588
+ rm -rf "$tmpd"
589
+
590
+ # If the DIRECT javac probe cannot even produce a rawtypes warning, the JDK
591
+ # itself is not behaving as measured, so nothing about the Maven path can be
592
+ # concluded -- report "not proven" rather than "effective".
593
+ if [ "$rc" -ne 0 ]; then
594
+ return 1
595
+ fi
596
+ printf '%s\n' "$out" | grep -q 'rawtypes'
597
+ }
598
+
599
+ # Is `-Xlint` reachable at all on this machine? The Maven probe only makes sense
600
+ # when a raw-type warning is obtainable; when javac cannot produce one, callers
601
+ # stay in degraded mode instead of claiming a clean lint.
602
+ gate13_maven_probe_available() {
603
+ command -v javac >/dev/null 2>&1
604
+ }
605
+
504
606
  gate13_java_sources() {
505
607
  git diff --cached --name-only --diff-filter=d -- '*.java' 2>/dev/null
506
608
  }
@@ -574,6 +676,23 @@ gate13_maven_injection_reliable() {
574
676
  return 1
575
677
  fi
576
678
 
679
+ # AC-670-33 (#659): every check above verifies that the arguments can be
680
+ # ASSEMBLED. None of them verifies that they take EFFECT, which is exactly how a
681
+ # pom declaring plugin 3.13.0 was declared "reliable" while Maven ran with no
682
+ # -Xlint at all and Gate 13 reported PASS on a file javac warned about 3 times.
683
+ #
684
+ # So prove it: one probe compile of a known-raw-type source through the same
685
+ # command shape. If the marker does not appear, injection did not happen and the
686
+ # caller must degrade -- "could not verify" is never "verified fine" (#499/#570).
687
+ if ! gate13_maven_probe_available; then
688
+ printf 'javac unavailable; -Xlint injection cannot be probed\n' >&2
689
+ return 1
690
+ fi
691
+ if ! gate13_maven_lint_effective "${MVN_BIN:-mvn}"; then
692
+ printf 'probe compile did not produce a [rawtypes] marker; -Xlint injection is NOT effective\n' >&2
693
+ return 1
694
+ fi
695
+
577
696
  return 0
578
697
  }
579
698
 
@@ -704,14 +823,20 @@ gate13_run_java_analysis() {
704
823
  fi
705
824
 
706
825
  # Count javac's lint output. Maven prefixes it with "[WARNING] ".
707
- mvn_findings=$(printf '%s\n' "$mvn_out" \
708
- | grep -cE '\[WARNING\].*\[(rawtypes|unchecked|deprecation|fallthrough|finally|cast|static|try|overrides|overloads|serial|synchronization|varargs|divzero|empty|exports|module|opens|options|path|processing|removal|requires|text-blocks|this-escape)\]' \
709
- | tr -d '[:space:]')
826
+ #
827
+ # #659: this used to be an English `[WARNING]...[allow-listed-category]`
828
+ # regex. Applied to a localised build -- or to a JDK category the list does
829
+ # not name -- it returned 0 on code javac had warned about, and the gate then
830
+ # printed PASSED. The shared counter below is marker- and locale-proof and
831
+ # fails closed (#499/#570). It lives in one place so the Maven path and the
832
+ # direct-javac path can never disagree about what counts as a finding.
833
+ mvn_findings=$(gate13_count_lint_findings "$mvn_out")
710
834
  mvn_findings=${mvn_findings:-0}
835
+ case "$mvn_findings" in ''|*[!0-9]*) mvn_findings=0 ;; esac
711
836
 
712
837
  if [ "$mvn_findings" -gt 0 ]; then
713
838
  printf '%s\n' "$mvn_out" \
714
- | grep -E '\[WARNING\].*\[[a-z-]+\]' | head -10 | sed 's/^/ /'
839
+ | grep -E '\[[a-z][a-z0-9-]*\]|warning:' | head -10 | sed 's/^/ /'
715
840
  echo " ❌ ${mvn_findings} javac -Xlint finding(s)"
716
841
  return "$GATE13_FAIL"
717
842
  fi
package/hooks/gate-4.sh CHANGED
@@ -46,7 +46,14 @@ java_coverage_note() {
46
46
  esac
47
47
  done
48
48
  [ -n "$_has_java" ] || return 0
49
- echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
49
+ # #628: `docs/` is not in the npm package's `files` list, so pointing a user at
50
+ # `docs/java-principles-coverage.md` was a dead path in every installed project --
51
+ # the note told them to read a file that does not exist on their machine. Cite the
52
+ # upstream URL instead, and keep the local path only when the doc is actually
53
+ # present (i.e. when running inside this repo).
54
+ local _where="https://github.com/boyingliu01/xp-gate/blob/main/docs/java-principles-coverage.md"
55
+ [ -f "docs/java-principles-coverage.md" ] && _where="docs/java-principles-coverage.md"
56
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see $_where)"
50
57
  }
51
58
 
52
59
  2>&1 echo ""
@@ -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
+ }