@boyingliu01/xp-gate 0.33.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/gate-3.sh +48 -7
  2. package/gate-8.sh +32 -3
  3. package/gate-9.sh +14 -2
  4. package/gates/gate-7.ts +31 -10
  5. package/hooks/gate-3.sh +48 -7
  6. package/hooks/gate-8.sh +32 -3
  7. package/hooks/gate-9.sh +14 -2
  8. package/hooks/lib/assertion-quality.sh +232 -0
  9. package/hooks/lib/diff-coverage.sh +223 -0
  10. package/hooks/pre-commit +226 -2
  11. package/lib/mattpocock-license-verdict.json +1 -1
  12. package/mock-policy/AGENTS.md +2 -2
  13. package/mutation/AGENTS.md +2 -2
  14. package/package.json +1 -1
  15. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  17. package/plugins/claude-code/skills/delphi-review/SKILL.md +30 -0
  18. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  19. package/plugins/claude-code/skills/test-driven-development/SKILL.md +19 -0
  20. package/plugins/claude-code/skills/test-driven-development/testing-anti-patterns.md +31 -0
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/dsh/skills/delphi-review/SKILL.md +30 -0
  25. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  26. package/plugins/dsh/skills/test-driven-development/SKILL.md +19 -0
  27. package/plugins/dsh/skills/test-driven-development/testing-anti-patterns.md +31 -0
  28. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/opencode/package.json +1 -1
  30. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/opencode/skills/delphi-review/SKILL.md +30 -0
  32. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  33. package/plugins/opencode/skills/test-driven-development/SKILL.md +19 -0
  34. package/plugins/opencode/skills/test-driven-development/testing-anti-patterns.md +31 -0
  35. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/qoder/plugin.json +1 -1
  37. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/qoder/skills/delphi-review/SKILL.md +30 -0
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  40. package/plugins/qoder/skills/test-driven-development/SKILL.md +19 -0
  41. package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +31 -0
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  43. package/principles/AGENTS.md +2 -2
  44. package/skills/delphi-review/AGENTS.md +2 -2
  45. package/skills/delphi-review/SKILL.md +30 -0
  46. package/skills/sprint-flow/AGENTS.md +2 -2
  47. package/skills/test-driven-development/SKILL.md +19 -0
  48. package/skills/test-driven-development/testing-anti-patterns.md +31 -0
  49. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/gate-3.sh CHANGED
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
172
172
  const entries = baseline.functions || baseline.entries || baseline;
173
173
  if (Array.isArray(entries)) {
174
174
  for (const e of entries) {
175
- if (!e || !e.file || !e.name) continue;
176
- known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
175
+ // #632: accept the key the baseline WRITER actually emits.
176
+ // tools/Update-ComplexityBaseline.ps1 (the tool this gate
177
+ // points at in its own fix-it message) writes "function",
178
+ // while this reader only knew "name" -- so every entry was
179
+ // dropped, "known" stayed empty, and every function above
180
+ // the threshold became a brand-new function. The ratchet
181
+ // could never pass. Both spellings are read: this is a
182
+ // reader, and the writer vocabulary is not ours to
183
+ // redefine from here.
184
+ if (!e || !e.file) continue;
185
+ const fnName = e.name || e.function;
186
+ if (!fnName) continue;
187
+ known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
177
188
  }
178
189
  }
179
190
  // The measurement emits ABSOLUTE paths (pwsh resolved them), while
@@ -529,13 +540,43 @@ else
529
540
  # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
530
541
  # reports PASSED on zero analysed functions -- the same silent-success
531
542
  # failure this issue is about.
543
+ #
544
+ # #666: "no rows" has TWO causes and they must not share a verdict. A
545
+ # module of pure constants legitimately has zero functions, and lizard
546
+ # emits no row for it at all (measured: `lizard pure_data.py --csv` prints
547
+ # nothing and exits 0). That was reported as `lizard_output_unparsable`
548
+ # /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
549
+ # data-only diff was indistinguishable from a tooling fault. The two are
550
+ # separated by a POSITIVE probe: ask lizard to describe a fixture known to
551
+ # contain a function. If it produces rows, lizard works and the input
552
+ # simply had no functions (SKIP, `no_functions_in_input`); if it does not,
553
+ # the tool really is broken (WARN, `lizard_output_unparsable`).
532
554
  if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
533
555
  END { exit(found ? 0 : 1) }'; then
534
- echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
535
- GATE_3_STATUS="WARN"
536
- # Consumed by the sourcing parent (pre-commit report JSON), not here.
537
- # shellcheck disable=SC2034
538
- GATE_3_REASON="lizard_output_unparsable"
556
+ _lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
557
+ _lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
558
+ printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
559
+ _lizard_probe_rows=0
560
+ if [ -f "$_lizard_probe_file" ]; then
561
+ _lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
562
+ | lizard_batches --csv 2>/dev/null \
563
+ | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
564
+ fi
565
+ rm -rf "$_lizard_probe_dir" 2>/dev/null || true
566
+ if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
567
+ echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
568
+ echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
569
+ GATE_3_STATUS="SKIP"
570
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
571
+ # shellcheck disable=SC2034
572
+ GATE_3_REASON="no_functions_in_input"
573
+ else
574
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
575
+ GATE_3_STATUS="WARN"
576
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
577
+ # shellcheck disable=SC2034
578
+ GATE_3_REASON="lizard_output_unparsable"
579
+ fi
539
580
  elif [ "$CC_WARNINGS" -gt 0 ]; then
540
581
  echo "$CC_OUTPUT"
541
582
  echo ""
package/gate-8.sh CHANGED
@@ -59,10 +59,24 @@ fi
59
59
  # revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
60
60
  # secret was never seen and Gate 8 always passed. `git --staged` scans
61
61
  # `git diff --cached`, which is exactly the pre-commit payload (#449).
62
+ #
63
+ # #616: `--all` is a different question, and the index is the wrong place to ask
64
+ # it. Under `xp-gate check . --all` the user asked "is the whole tree clean", the
65
+ # index is usually empty, `git --staged` legitimately found nothing, and that
66
+ # nothing was counted as PASSED -- a secret sitting in a tracked file was
67
+ # invisible to the one command meant to find it. The runner already exports
68
+ # GATE_SCOPE (#589); this fragment just has to read it. `dir --no-git` scans the
69
+ # filesystem so tracked-but-unmodified files are covered.
62
70
  GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
63
71
  rm -f "$GITLEAKS_REPORT"
64
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
65
- GITLEAKS_EXIT=$?
72
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
73
+ echo " Scope: --all (scanning the working tree, not the index)"
74
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD dir --no-git --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
75
+ GITLEAKS_EXIT=$?
76
+ else
77
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
78
+ GITLEAKS_EXIT=$?
79
+ fi
66
80
 
67
81
  if [ "$GITLEAKS_EXIT" -eq 0 ]; then
68
82
  # Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
@@ -183,7 +197,22 @@ fi
183
197
  # if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
184
198
  if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
185
199
  DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
186
- if git diff --cached --name-only --diff-filter=ACM -z \
200
+ # #616: same scope split as the gitleaks call above. Under `--all` the index is
201
+ # usually empty, so piping `git diff --cached -z` into xargs fed the hook zero
202
+ # files and it reported success without reading anything.
203
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
204
+ if git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
205
+ echo " ✅ detect-secrets PASSED - no new secrets beyond baseline (scope: --all)."
206
+ else
207
+ cat "$DS_TMP"
208
+ echo ""
209
+ echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
210
+ echo " If these are false positives, regenerate the baseline (cross-platform):"
211
+ echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
212
+ rm -f "$DS_TMP"
213
+ exit 1
214
+ fi
215
+ elif git diff --cached --name-only --diff-filter=ACM -z \
187
216
  | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
188
217
  echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
189
218
  else
package/gate-9.sh CHANGED
@@ -32,8 +32,20 @@ if [ -z "$SEMGREP_CMD" ]; then
32
32
  echo " Gate 9: SAST Security (WARN, semgrep not installed)"
33
33
  GATE_9_STATUS="WARN"
34
34
  else
35
- # Get staged files filtered to Semgrep-supported languages
36
- SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
35
+ # Get staged files filtered to Semgrep-supported languages.
36
+ #
37
+ # #616: under `--all` the index is usually empty, so the staged diff yielded no
38
+ # files and the gate reported SKIPPED -- "no supported language files changed"
39
+ # -- for a run whose whole purpose was to scan the tree. Same fail-open shape
40
+ # #589 fixed in Gate 3, one layer down: the CLI says the tree was checked and
41
+ # the fragment checks a diff that does not exist. The runner exports GATE_SCOPE;
42
+ # read it and enumerate tracked files instead.
43
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
44
+ echo " Scope: --all (scanning tracked files, not the index)"
45
+ SEMGREP_FILES=$(git ls-files 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
46
+ else
47
+ SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
48
+ fi
37
49
 
38
50
  if [ -z "$SEMGREP_FILES" ]; then
39
51
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
package/gates/gate-7.ts CHANGED
@@ -27,16 +27,13 @@ export function runGate7(input: GateInput): GateOutput {
27
27
  return { status: 'PASS', messages, exitCode: 0 };
28
28
  }
29
29
 
30
- // Check for IaC adapter script (bash path for pre-commit context)
31
- const adapterPath = findIaCAdapter(cwd);
32
- if (!adapterPath) {
33
- messages.push('ℹ️ SKIP - IaC adapter not found');
34
- messages.push('⏭️ SKIPPED - IaC security (no IaC adapter found)');
35
- recordAudit('gate-7', 'iac-security', 'SKIP', 0, startMs);
36
- return { status: 'SKIP', messages, exitCode: 0 };
37
- }
38
-
39
- // Run checkov if available (preferred tool)
30
+ // checkov is the PREFERRED engine (see the module header and the fallback
31
+ // message below), so it is tried FIRST. #650: the adapter lookup used to run
32
+ // before this and SKIP the gate whenever a consumer project had no vendored
33
+ // `githooks/adapters/iac.sh` -- even with checkov installed and on PATH. A
34
+ // security gate that skips because an optional helper script is absent, while
35
+ // the real scanner is available, reports "nothing to check" about code that was
36
+ // never checked. The bash adapter is the FALLBACK, so it is consulted after.
40
37
  const checkov = isToolAvailable('checkov', cwd);
41
38
  if (checkov.available) {
42
39
  return runCheckov(checkov.path, checkov.via, iacFiles, cwd, startMs, messages);
@@ -46,6 +43,30 @@ export function runGate7(input: GateInput): GateOutput {
46
43
  messages.push('⚠️ checkov not installed — falling back to bash IaC adapter');
47
44
  messages.push(' Install: pip install checkov');
48
45
 
46
+ return runBashAdapter(iacFiles, cwd, startMs, messages);
47
+ }
48
+
49
+ /**
50
+ * Fallback path: hand the IaC files to the project's bash adapter.
51
+ *
52
+ * Extracted from `runGate7` so the primary (checkov) path stays readable and the
53
+ * entry point stays inside the long-function limit. A missing adapter is a SKIP,
54
+ * never a PASS: no scan ran, so there is no evidence to report as clean.
55
+ */
56
+ function runBashAdapter(
57
+ iacFiles: string[],
58
+ cwd: string,
59
+ startMs: number,
60
+ messages: string[]
61
+ ): GateOutput {
62
+ const adapterPath = findIaCAdapter(cwd);
63
+ if (!adapterPath) {
64
+ messages.push('ℹ️ SKIP - IaC adapter not found');
65
+ messages.push('⏭️ SKIPPED - IaC security (no IaC adapter found)');
66
+ recordAudit('gate-7', 'iac-security', 'SKIP', 0, startMs);
67
+ return { status: 'SKIP', messages, exitCode: 0 };
68
+ }
69
+
49
70
  const result = runTool('bash', [adapterPath, ...iacFiles], { cwd });
50
71
  const combinedOutput = result.stdout + result.stderr;
51
72
 
package/hooks/gate-3.sh CHANGED
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
172
172
  const entries = baseline.functions || baseline.entries || baseline;
173
173
  if (Array.isArray(entries)) {
174
174
  for (const e of entries) {
175
- if (!e || !e.file || !e.name) continue;
176
- known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
175
+ // #632: accept the key the baseline WRITER actually emits.
176
+ // tools/Update-ComplexityBaseline.ps1 (the tool this gate
177
+ // points at in its own fix-it message) writes "function",
178
+ // while this reader only knew "name" -- so every entry was
179
+ // dropped, "known" stayed empty, and every function above
180
+ // the threshold became a brand-new function. The ratchet
181
+ // could never pass. Both spellings are read: this is a
182
+ // reader, and the writer vocabulary is not ours to
183
+ // redefine from here.
184
+ if (!e || !e.file) continue;
185
+ const fnName = e.name || e.function;
186
+ if (!fnName) continue;
187
+ known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
177
188
  }
178
189
  }
179
190
  // The measurement emits ABSOLUTE paths (pwsh resolved them), while
@@ -529,13 +540,43 @@ else
529
540
  # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
530
541
  # reports PASSED on zero analysed functions -- the same silent-success
531
542
  # failure this issue is about.
543
+ #
544
+ # #666: "no rows" has TWO causes and they must not share a verdict. A
545
+ # module of pure constants legitimately has zero functions, and lizard
546
+ # emits no row for it at all (measured: `lizard pure_data.py --csv` prints
547
+ # nothing and exits 0). That was reported as `lizard_output_unparsable`
548
+ # /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
549
+ # data-only diff was indistinguishable from a tooling fault. The two are
550
+ # separated by a POSITIVE probe: ask lizard to describe a fixture known to
551
+ # contain a function. If it produces rows, lizard works and the input
552
+ # simply had no functions (SKIP, `no_functions_in_input`); if it does not,
553
+ # the tool really is broken (WARN, `lizard_output_unparsable`).
532
554
  if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
533
555
  END { exit(found ? 0 : 1) }'; then
534
- echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
535
- GATE_3_STATUS="WARN"
536
- # Consumed by the sourcing parent (pre-commit report JSON), not here.
537
- # shellcheck disable=SC2034
538
- GATE_3_REASON="lizard_output_unparsable"
556
+ _lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
557
+ _lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
558
+ printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
559
+ _lizard_probe_rows=0
560
+ if [ -f "$_lizard_probe_file" ]; then
561
+ _lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
562
+ | lizard_batches --csv 2>/dev/null \
563
+ | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
564
+ fi
565
+ rm -rf "$_lizard_probe_dir" 2>/dev/null || true
566
+ if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
567
+ echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
568
+ echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
569
+ GATE_3_STATUS="SKIP"
570
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
571
+ # shellcheck disable=SC2034
572
+ GATE_3_REASON="no_functions_in_input"
573
+ else
574
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
575
+ GATE_3_STATUS="WARN"
576
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
577
+ # shellcheck disable=SC2034
578
+ GATE_3_REASON="lizard_output_unparsable"
579
+ fi
539
580
  elif [ "$CC_WARNINGS" -gt 0 ]; then
540
581
  echo "$CC_OUTPUT"
541
582
  echo ""
package/hooks/gate-8.sh CHANGED
@@ -59,10 +59,24 @@ fi
59
59
  # revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
60
60
  # secret was never seen and Gate 8 always passed. `git --staged` scans
61
61
  # `git diff --cached`, which is exactly the pre-commit payload (#449).
62
+ #
63
+ # #616: `--all` is a different question, and the index is the wrong place to ask
64
+ # it. Under `xp-gate check . --all` the user asked "is the whole tree clean", the
65
+ # index is usually empty, `git --staged` legitimately found nothing, and that
66
+ # nothing was counted as PASSED -- a secret sitting in a tracked file was
67
+ # invisible to the one command meant to find it. The runner already exports
68
+ # GATE_SCOPE (#589); this fragment just has to read it. `dir --no-git` scans the
69
+ # filesystem so tracked-but-unmodified files are covered.
62
70
  GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
63
71
  rm -f "$GITLEAKS_REPORT"
64
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
65
- GITLEAKS_EXIT=$?
72
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
73
+ echo " Scope: --all (scanning the working tree, not the index)"
74
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD dir --no-git --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
75
+ GITLEAKS_EXIT=$?
76
+ else
77
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
78
+ GITLEAKS_EXIT=$?
79
+ fi
66
80
 
67
81
  if [ "$GITLEAKS_EXIT" -eq 0 ]; then
68
82
  # Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
@@ -183,7 +197,22 @@ fi
183
197
  # if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
184
198
  if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
185
199
  DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
186
- if git diff --cached --name-only --diff-filter=ACM -z \
200
+ # #616: same scope split as the gitleaks call above. Under `--all` the index is
201
+ # usually empty, so piping `git diff --cached -z` into xargs fed the hook zero
202
+ # files and it reported success without reading anything.
203
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
204
+ if git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
205
+ echo " ✅ detect-secrets PASSED - no new secrets beyond baseline (scope: --all)."
206
+ else
207
+ cat "$DS_TMP"
208
+ echo ""
209
+ echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
210
+ echo " If these are false positives, regenerate the baseline (cross-platform):"
211
+ echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
212
+ rm -f "$DS_TMP"
213
+ exit 1
214
+ fi
215
+ elif git diff --cached --name-only --diff-filter=ACM -z \
187
216
  | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
188
217
  echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
189
218
  else
package/hooks/gate-9.sh CHANGED
@@ -32,8 +32,20 @@ if [ -z "$SEMGREP_CMD" ]; then
32
32
  echo " Gate 9: SAST Security (WARN, semgrep not installed)"
33
33
  GATE_9_STATUS="WARN"
34
34
  else
35
- # Get staged files filtered to Semgrep-supported languages
36
- SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
35
+ # Get staged files filtered to Semgrep-supported languages.
36
+ #
37
+ # #616: under `--all` the index is usually empty, so the staged diff yielded no
38
+ # files and the gate reported SKIPPED -- "no supported language files changed"
39
+ # -- for a run whose whole purpose was to scan the tree. Same fail-open shape
40
+ # #589 fixed in Gate 3, one layer down: the CLI says the tree was checked and
41
+ # the fragment checks a diff that does not exist. The runner exports GATE_SCOPE;
42
+ # read it and enumerate tracked files instead.
43
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
44
+ echo " Scope: --all (scanning tracked files, not the index)"
45
+ SEMGREP_FILES=$(git ls-files 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
46
+ else
47
+ SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
48
+ fi
37
49
 
38
50
  if [ -z "$SEMGREP_FILES" ]; then
39
51
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
@@ -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
+ }