@boyingliu01/xp-gate 0.35.17 → 0.35.19

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 (78) hide show
  1. package/adapters/gate-3.sh +94 -9
  2. package/adapters/gate-4.sh +27 -32
  3. package/adapters/gate-8.sh +32 -3
  4. package/adapters/gate-9.sh +14 -2
  5. package/gate-12-file-hygiene.sh +26 -4
  6. package/gate-13-compiler-strictness.sh +12 -0
  7. package/gates/gate-3.ts +77 -21
  8. package/gates/gate-8.ts +39 -26
  9. package/gates/gate-9.ts +4 -4
  10. package/hooks/gate-12-file-hygiene.sh +26 -4
  11. package/hooks/gate-13-compiler-strictness.sh +12 -0
  12. package/hooks/pre-commit +75 -11
  13. package/hooks/pre-push +11 -2
  14. package/lib/__tests__/doctor-delphi.test.js +114 -0
  15. package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
  16. package/lib/__tests__/doctor.test.js +9 -0
  17. package/lib/__tests__/gate-numbering.test.js +52 -0
  18. package/lib/__tests__/gate-runner.test.js +59 -14
  19. package/lib/__tests__/shared-utils.test.js +47 -0
  20. package/lib/__tests__/update-skill.test.js +77 -0
  21. package/lib/doctor-tui.js +23 -2
  22. package/lib/doctor.js +28 -7
  23. package/lib/gate-runner.js +98 -18
  24. package/lib/mattpocock-license-verdict.json +1 -1
  25. package/lib/shared-utils.js +27 -1
  26. package/lib/update-skill.js +59 -3
  27. package/mock-policy/AGENTS.md +3 -3
  28. package/mutation/AGENTS.md +3 -3
  29. package/package.json +1 -1
  30. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  31. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/claude-code/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  34. package/plugins/claude-code/skills/sprint-flow/references/definition-of-done.md +47 -0
  35. package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +16 -0
  36. package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +14 -0
  37. package/plugins/claude-code/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  38. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/dsh/package.json +1 -1
  40. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/dsh/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  43. package/plugins/dsh/skills/sprint-flow/references/definition-of-done.md +47 -0
  44. package/plugins/dsh/skills/sprint-flow/references/phase-4-verify.md +16 -0
  45. package/plugins/dsh/skills/sprint-flow/references/phase-5-ship.md +14 -0
  46. package/plugins/dsh/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  47. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  48. package/plugins/dsh/src/delphi-findings.test.ts +32 -6
  49. package/plugins/dsh/src/exit-code.test.ts +6 -0
  50. package/plugins/opencode/package.json +1 -1
  51. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  53. package/plugins/opencode/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  54. package/plugins/opencode/skills/sprint-flow/references/definition-of-done.md +47 -0
  55. package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +16 -0
  56. package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +14 -0
  57. package/plugins/opencode/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  58. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  59. package/plugins/qoder/plugin.json +1 -1
  60. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  61. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  62. package/plugins/qoder/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  63. package/plugins/qoder/skills/sprint-flow/references/definition-of-done.md +47 -0
  64. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +16 -0
  65. package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +14 -0
  66. package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  67. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/principles/AGENTS.md +3 -3
  69. package/scripts/__tests__/sync-package-content.test.js +114 -46
  70. package/scripts/sync-package-content.js +32 -1
  71. package/skills/delphi-review/AGENTS.md +3 -3
  72. package/skills/sprint-flow/AGENTS.md +3 -3
  73. package/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  74. package/skills/sprint-flow/references/definition-of-done.md +47 -0
  75. package/skills/sprint-flow/references/phase-4-verify.md +16 -0
  76. package/skills/sprint-flow/references/phase-5-ship.md +14 -0
  77. package/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  78. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -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
@@ -450,6 +461,52 @@ else
450
461
  GATE_3_DETAIL="mode=ratchet verifier=MISSING"
451
462
  exit 1
452
463
  fi
464
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
465
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
466
+ # #617: grade exactly the population the baseline was written from.
467
+ #
468
+ # The comment below has always said so, but only as a comment: a repo whose
469
+ # baseline came from a narrower scan (a Java-only ccn-report, say) had its
470
+ # vendored minified JS graded anyway, and --csv-verified then disagreed with
471
+ # lizard on single-line code and fail-closed with "could not be
472
+ # span-verified". That BLOCK has no repair: the file has no baseline entry
473
+ # to ratchet, so there is nothing the user could add to clear it.
474
+ #
475
+ # The population filter runs BEFORE verification on purpose. Domain
476
+ # restriction is decidable from the path alone, so asking the expensive
477
+ # span re-derivation about a file that will not be graded anyway only
478
+ # manufactures failures for code the ratchet never claims to cover.
479
+ if [ -f "$RATCHET_LIB" ]; then
480
+ # shellcheck source=/dev/null
481
+ . "$RATCHET_LIB" 2>/dev/null || true
482
+ if declare -F ccn_file_in_population >/dev/null 2>&1; then
483
+ _cc_pop_kept=""
484
+ _cc_pop_skipped=""
485
+ while IFS= read -r _cc_row; do
486
+ [ -n "$_cc_row" ] || continue
487
+ _cc_row_path=$(printf '%s' "$_cc_row" | awk -F, '{print $1}')
488
+ if ccn_file_in_population ".ccn-baseline.json" "$_cc_row_path"; then
489
+ _cc_pop_kept="${_cc_pop_kept}${_cc_row}
490
+ "
491
+ else
492
+ _cc_pop_skipped="${_cc_pop_skipped}${_cc_row_path}
493
+ "
494
+ fi
495
+ done <<CC_POP_EOF
496
+ $CC_CSV
497
+ CC_POP_EOF
498
+ CC_CSV=$(printf '%s' "$_cc_pop_kept")
499
+ if [ -n "$_cc_pop_skipped" ]; then
500
+ _cc_pop_skip_n=$(printf '%s' "$_cc_pop_skipped" | grep -c . || true)
501
+ echo "ℹ️ Gate 3: ${_cc_pop_skip_n:-0} file(s) outside the baseline population — not graded, not ratcheted:"
502
+ printf '%s' "$_cc_pop_skipped" | sed 's/^/ /' | sed -n '1,5p'
503
+ if [ "${_cc_pop_skip_n:-0}" -gt 5 ]; then
504
+ echo " ... and $((_cc_pop_skip_n - 5)) more"
505
+ fi
506
+ echo " (declare or widen the population in .ccn-baseline.json to grade them)"
507
+ fi
508
+ fi
509
+ fi
453
510
  if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
454
511
  _cc_verify_failed=0
455
512
  CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
@@ -460,8 +517,6 @@ else
460
517
  fi
461
518
  CC_CSV="$CC_CSV_VERIFIED"
462
519
  fi
463
- RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
464
- [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
465
520
  # Fail closed when the ratchet library is missing. Sourcing a nonexistent
466
521
  # file only prints "No such file or directory" and leaves
467
522
  # evaluate_ccn_ratchet undefined; the verdict then came back empty, every
@@ -529,13 +584,43 @@ else
529
584
  # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
530
585
  # reports PASSED on zero analysed functions -- the same silent-success
531
586
  # failure this issue is about.
587
+ #
588
+ # #666: "no rows" has TWO causes and they must not share a verdict. A
589
+ # module of pure constants legitimately has zero functions, and lizard
590
+ # emits no row for it at all (measured: `lizard pure_data.py --csv` prints
591
+ # nothing and exits 0). That was reported as `lizard_output_unparsable`
592
+ # /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
593
+ # data-only diff was indistinguishable from a tooling fault. The two are
594
+ # separated by a POSITIVE probe: ask lizard to describe a fixture known to
595
+ # contain a function. If it produces rows, lizard works and the input
596
+ # simply had no functions (SKIP, `no_functions_in_input`); if it does not,
597
+ # the tool really is broken (WARN, `lizard_output_unparsable`).
532
598
  if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
533
599
  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"
600
+ _lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
601
+ _lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
602
+ printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
603
+ _lizard_probe_rows=0
604
+ if [ -f "$_lizard_probe_file" ]; then
605
+ _lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
606
+ | lizard_batches --csv 2>/dev/null \
607
+ | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
608
+ fi
609
+ rm -rf "$_lizard_probe_dir" 2>/dev/null || true
610
+ if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
611
+ echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
612
+ echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
613
+ GATE_3_STATUS="SKIP"
614
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
615
+ # shellcheck disable=SC2034
616
+ GATE_3_REASON="no_functions_in_input"
617
+ else
618
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
619
+ GATE_3_STATUS="WARN"
620
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
621
+ # shellcheck disable=SC2034
622
+ GATE_3_REASON="lizard_output_unparsable"
623
+ fi
539
624
  elif [ "$CC_WARNINGS" -gt 0 ]; then
540
625
  echo "$CC_OUTPUT"
541
626
  echo ""
@@ -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 ""
@@ -165,42 +172,30 @@ else
165
172
  rm -f "$PRINCIPLES_STDERR"
166
173
 
167
174
  if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
168
- # Fail-closed (#661), same rule as Gate 8 (#499): a gate that cannot run
169
- # must not report success.
175
+ # #661: exit>=2 means the checker did not RUN (crash, module not found,
176
+ # bad invocation). This used to record SKIP, and pre-commit treats SKIP
177
+ # as non-blocking, so a tool failure was absorbed into the green light --
178
+ # the reproduction printed a module-not-found stack trace and then
179
+ # "✅ PASSED - Principles checker (no errors found)".
170
180
  #
171
- # This branch used to settle on SKIP, which the caller reads as
172
- # non-blocking -- so a checker that crashed on startup
173
- # (`Cannot find module 'C:\c\Users\...'`, the POSIX-path-to-native-node
174
- # bug) still printed "PASSED - Principles checker (no errors found)".
175
- # The path conversion fixed that one cause; any other way of breaking
176
- # the launch (missing tsx, corrupt module, OOM) lands here too, and all
177
- # of them would have been absorbed into a green light.
181
+ # The distinction the gate must keep:
182
+ # - tool NOT INSTALLED -> SKIP is honest; nothing is being claimed.
183
+ # - tool PRESENT, CRASH -> WARN. Not a pass (nothing was checked) and
184
+ # not a silent skip (the user must know their code went unaudited).
178
185
  #
179
- # SKIP stays reserved for "language not applicable / tool not
180
- # installed", which is not what this is: the checker exists and failed.
181
- echo ""
182
- echo "❌ BLOCKED - Principles checker failed to execute (exit $PRINCIPLES_EXIT)"
183
- echo "This is a tool fault, NOT a clean result. A gate that cannot run must not pass."
184
- echo " Fix the checker, or set PRINCIPLES_SKIP_TOOL_FAULT=1 to downgrade to a warning."
185
- echo " (downgrade is audit-logged and never allowed on main/master)"
186
- GATE_4_STATUS="FAIL"
186
+ # WARN rather than FAIL on purpose: the commit itself is not necessarily
187
+ # bad, the TOOL is broken, and blocking every commit until node/tsx is
188
+ # repaired would blame the change for an environment fault. What must
189
+ # never happen is the green light.
190
+ echo "⚠️ Principles checker did not run (exit ${PRINCIPLES_EXIT}) — this code is NOT verified."
191
+ echo " Gate 4 cannot claim a pass for a check that never executed."
192
+ echo " Fix the checker invocation above (or run 'npx xp-gate check principles .')."
193
+ echo "⏭️ WARN - Principles check (checker execution error)"
194
+ GATE_4_STATUS="WARN"
195
+ # Consumed by the sourcing parent, not here (see #548).
187
196
  # shellcheck disable=SC2034
188
197
  GATE_4_REASON="checker_execution_error"
189
198
  rm -f "$PRINCIPLES_JSON"
190
- if [ "${PRINCIPLES_SKIP_TOOL_FAULT:-0}" = "1" ]; then
191
- echo "⚠️ ESCAPE VALVE: PRINCIPLES_SKIP_TOOL_FAULT=1 — downgrading to SKIP"
192
- # Same asymmetry as Gate 5a: a downgrade is tolerable on a feature
193
- # branch, never on main/master, where it would green-light the trunk.
194
- _g4_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
195
- if [ "$_g4_branch" = "main" ] || [ "$_g4_branch" = "master" ]; then
196
- echo "❌ ESCAPE VALVE BLOCKED: PRINCIPLES_SKIP_TOOL_FAULT not allowed on $_g4_branch"
197
- GATE_4_STATUS="FAIL"
198
- exit 1
199
- fi
200
- GATE_4_STATUS="SKIP"
201
- else
202
- exit 1
203
- fi
204
199
  else
205
200
  # Check severity levels. The reporter emits JSON.stringify(out, null, 2),
206
201
  # i.e. `"severity": "warning"` WITH a space; tolerate any whitespace so a
@@ -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
@@ -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)"
@@ -36,13 +36,35 @@ fi
36
36
  HYGIENE_ISSUES=0
37
37
  HYGIENE_FILES=""
38
38
 
39
+ # Is this staged file binary (so line-based checks do not apply)?
40
+ #
41
+ # This replaces `grep -q $'\x00'`, which was a no-op guard: bash strips NUL from
42
+ # `$'\x00'`, so the pattern was the EMPTY string and `grep -q ""` matches any
43
+ # non-empty input. Every staged file was therefore judged binary and `continue`d,
44
+ # silently disabling Check 1 (trailing whitespace), Check 2 (missing EOF newline)
45
+ # and -- worst -- Check 3 (merge conflict markers, a BLOCK-level check). Checks 4
46
+ # and 5 lacked the guard, which is why the gate looked half-working.
47
+ #
48
+ # Ask git instead of guessing: `--numstat` reports a binary entry as `-\t-\t<path>`
49
+ # because git already made that determination. A byte-level NUL test cannot be
50
+ # written with a shell pattern at all (variables and command substitution both drop
51
+ # NUL), and `grep -I` only suppresses output on a binary input -- it does not make
52
+ # `grep -q` fail, which is why that shortcut still reported text.
53
+ is_binary_staged_file() {
54
+ local _file="$1"
55
+ case "$(git diff --cached --numstat -- "$_file" 2>/dev/null)" in
56
+ -[[:space:]]-*) return 0 ;; # binary: git could not count lines
57
+ *) return 1 ;; # text (or empty -- nothing for the line checks)
58
+ esac
59
+ }
60
+
39
61
  # --- Check 1: Trailing whitespace ---
40
62
  TRAILING_WS_FILES=""
41
63
  if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
42
64
  while IFS= read -r file; do
43
65
  [ -z "$file" ] && continue
44
- # Skip binary files by checking if file contains null bytes
45
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
66
+ # Skip binary files : they have no lines to check
67
+ if is_binary_staged_file "$file"; then
46
68
  continue
47
69
  fi
48
70
  # Check staged content for trailing whitespace (spaces/tabs at end of lines)
@@ -69,7 +91,7 @@ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
69
91
  while IFS= read -r file; do
70
92
  [ -z "$file" ] && continue
71
93
  # Skip binary files
72
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
94
+ if is_binary_staged_file "$file"; then
73
95
  continue
74
96
  fi
75
97
  # Check if file content ends with newline
@@ -108,7 +130,7 @@ else
108
130
  while IFS= read -r file; do
109
131
  [ -z "$file" ] && continue
110
132
  # Skip binary files
111
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
133
+ if is_binary_staged_file "$file"; then
112
134
  continue
113
135
  fi
114
136
  # Check for conflict markers in staged content
@@ -527,6 +527,18 @@ gate13_count_lint_findings() {
527
527
  [ -n "$src" ] || src=$(cat)
528
528
  printf '%s\n' "$src" | awk '
529
529
  BEGIN { n = 0 }
530
+ # (#673) Maven 3.9.7+ reactor summary appends the packaging type per module:
531
+ # [INFO] whaleaudit-core [jar]
532
+ # `[pom]`/`[jar]` match the open-category rule below, so a 3-module reactor
533
+ # reported 4 javac findings on code with ZERO warnings -- and got worse with
534
+ # every module added. The marker set is closed (packaging types are an enum in
535
+ # Maven itself), so excluding it does not reopen the #659 fail-open hole that
536
+ # the open-category rule was introduced to close.
537
+ #
538
+ # Scoped to `[INFO]` lines: a javac diagnostic is never prefixed with the
539
+ # Maven [INFO] banner, so this cannot mask a real finding even when a category
540
+ # name collides with a packaging type.
541
+ /^\[INFO\].*\[(pom|jar|war|ear|rar|ejb|maven-plugin|par|test-jar|java-source|javadoc|zip|tar\.gz|tar\.bz2)\][[:space:]]*$/ { next }
530
542
  # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
543
  # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
544
  # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
package/gates/gate-3.ts CHANGED
@@ -53,29 +53,85 @@ export function runGate3(input: GateInput): GateOutput {
53
53
 
54
54
  messages.push('Checking complexity for source files...');
55
55
 
56
- // Run lizard with CCN threshold
57
- const result = runTool(lizardCmd, [...lizardArgs, '-C', String(CCN_THRESHOLD), ...ccFiles], {
58
- cwd: input.cwd,
59
- });
60
-
61
- const combinedOutput = result.stdout + result.stderr;
62
-
63
- // Parse warning count from summary table: "Warning cnt 8"
64
- const warningMatch = combinedOutput.match(/^Warning cnt\s+(\d+)/m);
65
- const warnings = warningMatch ? parseInt(warningMatch[1], 10) : 0;
66
-
67
- if (warnings > 0) {
68
- messages.push(combinedOutput);
69
- messages.push('');
70
- messages.push(`❌ BLOCKED - ${warnings} functions with CCN > ${CCN_THRESHOLD} found.`);
71
- messages.push(`Refactor high-complexity functions to keep below ${CCN_THRESHOLD} complexity.`);
72
- recordAudit('gate-3', 'complexity', 'FAIL', warnings, startMs);
73
- return { status: 'FAIL', messages, exitCode: 1 };
56
+ // CSV mode: the verdict comes from the numbers lizard uses, not its table
57
+ // formatting (#674). See countCcnWarnings() for why the old parser never matched.
58
+ const rawOutput = runLizardCsv(lizardCmd, lizardArgs, ccFiles, input.cwd);
59
+ const verdict = buildComplexityVerdict(countCcnWarnings(rawOutput), rawOutput);
60
+ messages.push(...verdict.messages);
61
+ recordAudit('gate-3', 'complexity', verdict.status, verdict.warnings, startMs);
62
+ return { status: verdict.status, messages, exitCode: verdict.exitCode };
63
+ }
64
+
65
+ /** Run lizard in CSV mode and return its raw output (stdout + stderr). */
66
+ function runLizardCsv(cmd: string, baseArgs: string[], files: string[], cwd: string | undefined): string {
67
+ const result = runTool(cmd, [...baseArgs, '--csv', '-C', String(CCN_THRESHOLD), ...files], { cwd });
68
+ return result.stdout + result.stderr;
69
+ }
70
+
71
+ /**
72
+ * Turn a CCN count into the gate's verdict and messages.
73
+ *
74
+ * Split from runGate3 so the three-way outcome (WARN on unparsable output, FAIL
75
+ * on violations, PASS) is visible in one place and testable without driving
76
+ * lizard. Notably `!analysed` must NOT fall through to PASS: a lizard that
77
+ * produced no parsable row would otherwise report success on zero analysed
78
+ * functions -- the same silent-success failure as #674.
79
+ */
80
+ export function buildComplexityVerdict(
81
+ counts: { warnings: number; analysed: boolean },
82
+ rawOutput: string
83
+ ): { status: 'PASS' | 'FAIL' | 'WARN'; messages: string[]; exitCode: number; warnings: number } {
84
+ if (!counts.analysed) {
85
+ return {
86
+ status: 'WARN',
87
+ messages: ['⚠️ WARN - lizard produced no parsable results; complexity not verified'],
88
+ exitCode: 0,
89
+ warnings: 0,
90
+ };
91
+ }
92
+ if (counts.warnings > 0) {
93
+ return {
94
+ status: 'FAIL',
95
+ messages: [
96
+ rawOutput,
97
+ '',
98
+ `❌ BLOCKED - ${counts.warnings} functions with CCN > ${CCN_THRESHOLD} found.`,
99
+ `Refactor high-complexity functions to keep below ${CCN_THRESHOLD} complexity.`,
100
+ ],
101
+ exitCode: 1,
102
+ warnings: counts.warnings,
103
+ };
74
104
  }
105
+ return {
106
+ status: 'PASS',
107
+ messages: [`✅ PASSED - All functions within complexity threshold (${CCN_THRESHOLD}).`],
108
+ exitCode: 0,
109
+ warnings: 0,
110
+ };
111
+ }
75
112
 
76
- messages.push(`✅ PASSED - All functions within complexity threshold (${CCN_THRESHOLD}).`);
77
- recordAudit('gate-3', 'complexity', 'PASS', 0, startMs);
78
- return { status: 'PASS', messages, exitCode: 0 };
113
+ /**
114
+ * Count functions whose CCN exceeds the threshold from lizard's --csv output.
115
+ *
116
+ * Split out so the parse can be tested against literal lizard output instead of
117
+ * by driving the tool: a parser bug here is invisible in exactly the same way
118
+ * (#674) -- it returns "clean" and the gate reports PASS.
119
+ *
120
+ * `analysed` distinguishes "lizard found nothing over the threshold" from
121
+ * "lizard produced nothing at all". Only the first is a pass.
122
+ */
123
+ export function countCcnWarnings(csv: string): { warnings: number; analysed: boolean } {
124
+ let warnings = 0;
125
+ let analysed = false;
126
+ for (const line of csv.split('\n')) {
127
+ const columns = line.split(',');
128
+ if (columns.length < 2) continue;
129
+ // Location column carries ":name" style text, so only trust numeric CCN.
130
+ if (!/^\s*\d+\s*$/.test(columns[1])) continue;
131
+ analysed = true;
132
+ if (parseInt(columns[1], 10) > CCN_THRESHOLD) warnings++;
133
+ }
134
+ return { warnings, analysed };
79
135
  }
80
136
 
81
137
  // ── CLI Entry Point ──
package/gates/gate-8.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  * Gate 8: Secret Scanning (gitleaks)
3
3
  * Detects secrets (API keys, passwords, tokens) in staged files.
4
4
  * TypeScript rewrite of githooks/adapters/gate-8.sh for cross-platform Node.js CLI.
5
+ *
6
+ * Scans the INDEX (`git --staged`), not committed history (#675, mirroring the
7
+ * adapter fix in #449). gitleaks `git --pre-commit` walks committed revisions; on a
8
+ * pre-commit run it reports "0 commits scanned" and exits 0. A staged secret
9
+ * therefore passed the CLI while the hook blocked it -- and the CLI is the path CI
10
+ * and the plugins use. `--staged` scans `git diff --cached`, exactly the
11
+ * pre-commit payload. Same tool, same payload, one word different, opposite verdict.
5
12
  */
6
13
  import path from 'path';
7
14
  import fs from 'fs';
@@ -29,20 +36,7 @@ export function runGate8(input: GateInput): GateOutput {
29
36
  }
30
37
 
31
38
  const gitleaksCmd = tool.via === 'npx' ? 'npx' : tool.path;
32
- const gitleaksArgs = tool.via === 'npx' ? ['-y', 'gitleaks'] : [];
33
-
34
- // Config detection
35
- const configPath = findGitleaksConfig(cwd);
36
- if (configPath) {
37
- gitleaksArgs.push(`--config=${configPath}`);
38
- }
39
-
40
- // Cross-platform temp path (fixes /tmp issue on Windows)
41
- const reportPath = path.join(getTempDir(), 'gitleaks-report.json');
42
-
43
- // Run gitleaks on staged changes
44
- gitleaksArgs.push('git', '--pre-commit', '--redact', '--no-banner',
45
- '--report-format=json', `--report-path=${reportPath}`);
39
+ const gitleaksArgs = buildGitleaksArgs(tool.via === 'npx', findGitleaksConfig(cwd));
46
40
 
47
41
  const result = runTool(gitleaksCmd, gitleaksArgs, { cwd });
48
42
 
@@ -53,33 +47,52 @@ export function runGate8(input: GateInput): GateOutput {
53
47
  }
54
48
 
55
49
  if (result.exitCode === 1) {
56
- // Secrets found
57
- messages.push(result.stdout);
58
- messages.push('');
59
- messages.push('❌ BLOCKED - Secrets detected in staged files.');
60
- messages.push('');
61
- messages.push('Remediation options:');
62
- messages.push(' 1. Remove the secret and use environment variables instead');
63
- messages.push(' 2. Add a false positive to .gitleaks.toml allowlist');
64
- messages.push(' 3. Use git secret or vault for sensitive data');
65
- messages.push('');
66
- messages.push('See: https://github.com/gitleaks/gitleaks');
50
+ messages.push(...secretsFoundMessages(result.stdout));
67
51
  recordAudit('gate-8', 'secret-scanning', 'FAIL', 1, startMs);
68
52
  return { status: 'FAIL', messages, exitCode: 1 };
69
53
  }
70
54
 
71
- // Runtime error
72
55
  messages.push(` ⚠️ gitleaks exited with code ${result.exitCode} - skipping gate`);
73
56
  messages.push(' ✅ Secret Scanning (SKIP, gitleaks error)');
74
57
  recordAudit('gate-8', 'secret-scanning', 'SKIP', result.exitCode, startMs);
75
58
  return { status: 'SKIP', messages, exitCode: 0 };
76
59
  }
77
60
 
61
+ /** The report + remediation shown when gitleaks finds secrets (exit code 1). */
62
+ function secretsFoundMessages(stdout: string): string[] {
63
+ return [
64
+ stdout,
65
+ '',
66
+ '❌ BLOCKED - Secrets detected in staged files.',
67
+ '',
68
+ 'Remediation options:',
69
+ ' 1. Remove the secret and use environment variables instead',
70
+ ' 2. Add a false positive to .gitleaks.toml allowlist',
71
+ ' 3. Use git secret or vault for sensitive data',
72
+ '',
73
+ 'See: https://github.com/gitleaks/gitleaks',
74
+ ];
75
+ }
76
+
78
77
  function findGitleaksConfig(cwd: string): string | null {
79
78
  const configPath = path.join(cwd, '.gitleaks.toml');
80
79
  return fs.existsSync(configPath) ? configPath : null;
81
80
  }
82
81
 
82
+ /**
83
+ * Assemble the gitleaks argument vector.
84
+ *
85
+ * Report goes to a cross-platform temp path (the old hardcoded `/tmp` broke on
86
+ * Windows), and the scan reads the INDEX -- see the file header for why (#675).
87
+ */
88
+ function buildGitleaksArgs(viaNpx: boolean, configPath: string | null): string[] {
89
+ const args = viaNpx ? ['-y', 'gitleaks'] : [];
90
+ if (configPath) args.push(`--config=${configPath}`);
91
+ args.push('git', '--staged', '--redact', '--no-banner',
92
+ '--report-format=json', `--report-path=${path.join(getTempDir(), 'gitleaks-report.json')}`);
93
+ return args;
94
+ }
95
+
83
96
  // ── CLI Entry Point ──
84
97
  if (process.argv.includes('--run')) {
85
98
  const cwdIdx = process.argv.indexOf('--cwd');
package/gates/gate-9.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Gate 9: Semgrep SAST Security Scan
2
+ * Gate 10: Semgrep SAST Security Scan (file name predates the #677 renumber)
3
3
  * Detects security vulnerabilities in staged files.
4
4
  * For PowerShell projects: uses PSScriptAnalyzer security rules as alternative.
5
5
  * TypeScript rewrite of githooks/adapters/gate-9.sh for cross-platform Node.js CLI.
@@ -36,7 +36,7 @@ export function runGate9(input: GateInput): GateOutput {
36
36
  const messages: string[] = [];
37
37
 
38
38
  messages.push('');
39
- messages.push('→ Gate 9: Semgrep SAST Security Scan...');
39
+ messages.push('→ Gate 10: Semgrep SAST Security Scan...');
40
40
 
41
41
  // PowerShell project: use PSScriptAnalyzer security rules
42
42
  if (isPowerShellProject(input.projectLang)) {
@@ -49,7 +49,7 @@ export function runGate9(input: GateInput): GateOutput {
49
49
  messages.push(' ⚠️ WARN - semgrep not installed — SAST scanning unavailable');
50
50
  messages.push(' Install: brew install semgrep (macOS) | pip install semgrep (Linux/Windows)');
51
51
  messages.push(' Pre-cache rules: semgrep --config=' + SEMGREP_RULESET);
52
- messages.push(' Gate 9: SAST Security (WARN, semgrep not installed)');
52
+ messages.push(' Gate 10: SAST Security (WARN, semgrep not installed)');
53
53
  recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
54
54
  return { status: 'WARN', messages, exitCode: 0 };
55
55
  }
@@ -242,7 +242,7 @@ function runPowerShellSAST(
242
242
  const ps = detectPowerShell();
243
243
  if (!ps.available) {
244
244
  messages.push(' ⚠️ WARN - PowerShell not available — SAST scanning unavailable');
245
- messages.push(' Gate 9: SAST Security (WARN, PowerShell not installed)');
245
+ messages.push(' Gate 10: SAST Security (WARN, PowerShell not installed)');
246
246
  recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
247
247
  return { status: 'WARN', messages, exitCode: 0 };
248
248
  }