@boyingliu01/xp-gate 0.35.6 → 0.35.8

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 (37) hide show
  1. package/bin/xp-gate.js +60 -0
  2. package/hooks/lib/assertion-quality.sh +47 -0
  3. package/hooks/pre-commit +131 -21
  4. package/lib/__tests__/assertion-debt.test.js +99 -0
  5. package/lib/__tests__/gate-rules.test.js +58 -0
  6. package/lib/__tests__/mirror-manifest.test.js +64 -0
  7. package/lib/assertion-debt.js +173 -0
  8. package/lib/gate-rules.js +149 -0
  9. package/lib/mattpocock-license-verdict.json +1 -1
  10. package/lib/mirror-manifest.js +112 -0
  11. package/mock-policy/AGENTS.md +2 -2
  12. package/mutation/AGENTS.md +2 -2
  13. package/package.json +1 -1
  14. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  15. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  16. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  17. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +14 -1
  18. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  19. package/plugins/dsh/package.json +1 -1
  20. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  21. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  22. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +14 -1
  23. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  24. package/plugins/opencode/package.json +1 -1
  25. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  26. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  27. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +14 -1
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/principles/AGENTS.md +2 -2
  34. package/skills/delphi-review/AGENTS.md +2 -2
  35. package/skills/sprint-flow/AGENTS.md +2 -2
  36. package/skills/sprint-flow/references/phase-3-build.md +14 -1
  37. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -365,6 +365,21 @@ const COMMANDS = {
365
365
  },
366
366
  usage: 'xp-gate gate-coverage [--json] [--debt] [--strict] [--branch <name>]'
367
367
  },
368
+ 'gate-rules': {
369
+ description: 'Print the gate limits that can block a commit, generated from the files that define them (#652)',
370
+ run: subargs => {
371
+ const { collectGateRules, renderGateRules } = require('../lib/gate-rules.js');
372
+ const rules = collectGateRules({ cwd: process.cwd() });
373
+ if (subargs.includes('--json')) {
374
+ process.stdout.write(`${JSON.stringify(rules, null, 2)}\n`);
375
+ return;
376
+ }
377
+ // Generated, never hand-copied: a checklist that drifts from the config it
378
+ // describes teaches the wrong rule and then contradicts it at commit time.
379
+ process.stdout.write(`${renderGateRules(rules)}\n`);
380
+ },
381
+ usage: 'xp-gate gate-rules [--json]'
382
+ },
368
383
  'rework': {
369
384
  description: 'Report rework: first-pass rate, rounds before landing, top blocking reasons (#651)',
370
385
  run: subargs => {
@@ -673,15 +688,60 @@ function handleAudit(args) {
673
688
  return;
674
689
  }
675
690
 
691
+ // #668: pre-existing assertion debt, so it can be cleared in batches instead of
692
+ // being discovered one unrelated commit at a time.
693
+ if (args[0] === 'assertions') {
694
+ handleAuditAssertions(args);
695
+ return;
696
+ }
697
+
676
698
  console.error('Error: Unknown audit subcommand or missing flag');
677
699
  console.error('Usage:');
678
700
  console.error(' xp-gate audit record --gate-id X --gate-name Y --passed true/false --issues-found N --duration-ms N --trigger commit');
679
701
  console.error(' xp-gate audit --tail [N] (default: 20)');
680
702
  console.error(' xp-gate audit --stats (per-gate stats + anomaly summary)');
681
703
  console.error(' xp-gate audit --check (exit 1 if the ledger has anomalies)');
704
+ console.error(' xp-gate audit assertions (list pre-existing assertion debt across the repo; --json)');
682
705
  process.exit(1);
683
706
  }
684
707
 
708
+ /**
709
+ * `xp-gate audit assertions` (#668) — list pre-existing vacuous assertions.
710
+ *
711
+ * Gate 5d only inspects files in the diff, so standing debt surfaces during
712
+ * unrelated commits and reads as if that change introduced it. This makes the debt
713
+ * a work queue instead of a series of false accusations.
714
+ *
715
+ * Advisory: always exits 0. A debt list that fails builds becomes a number people
716
+ * learn to ignore.
717
+ */
718
+ function handleAuditAssertions(args) {
719
+ const { scanForAssertionDebt, summarizeDebt } = require(path.join(__dirname, '..', 'lib', 'assertion-debt.js'));
720
+ const results = scanForAssertionDebt({ cwd: process.cwd() });
721
+ const summary = summarizeDebt(results);
722
+ if (args.includes('--json')) {
723
+ process.stdout.write(`${JSON.stringify({ summary, files: results }, null, 2)}\n`);
724
+ return;
725
+ }
726
+ if (summary.total === 0) {
727
+ console.log('✅ No pre-existing assertion debt found.');
728
+ return;
729
+ }
730
+ console.log(`Found ${summary.total} pre-existing assertion issue(s) in ${summary.files} file(s):`);
731
+ console.log(` tautological ${summary.byKind.tautological}`);
732
+ console.log(` swallowed-error ${summary.byKind['swallowed-error']}`);
733
+ console.log('');
734
+ for (const entry of results) {
735
+ console.log(`${entry.file} (${entry.findings.length})`);
736
+ for (const finding of entry.findings) {
737
+ console.log(` ${finding.line}: [${finding.kind}] ${finding.text}`);
738
+ }
739
+ }
740
+ console.log('');
741
+ console.log('These predate the current diff and would be reported as "introduced by this change"');
742
+ console.log('if the file is touched. See skills/test-driven-development/testing-anti-patterns.md');
743
+ }
744
+
685
745
  function printAuditTable(entries) {
686
746
  console.log('');
687
747
  console.log('Recent Audit Entries:');
@@ -178,6 +178,53 @@ classify_swallowed_errors() {
178
178
  grep -nE 'catch([[:space:]]*\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' || true
179
179
  }
180
180
 
181
+ # #668: is the flagged line part of THIS change, or merely carried in by the file?
182
+ #
183
+ # Gate 5d runs over whole files, so a file that was edited anywhere gets all of its
184
+ # assertions re-examined. Pre-existing debt then blocks an unrelated commit and
185
+ # reads as "this change introduced it". The author who fixed `writeReport()`'s error
186
+ # handling was stopped by four `expect(true).toBe(true)` lines that had been there
187
+ # for months.
188
+ #
189
+ # The cost is not the one false block. It is that the noise teaches people to add
190
+ # `@assertion-justified` rather than fix the test -- a gate that trains people to
191
+ # annotate past it is worse than no gate.
192
+ #
193
+ # Args: $1 = file path, $2 = line number, $3 = added-line ranges ("start-end start-end")
194
+ # Returns 0 when the line is inside an added hunk (i.e. genuinely introduced here).
195
+ assertion_line_is_new() {
196
+ local _aq_file="$1" _aq_line="$2" _aq_ranges="$3"
197
+ local _aq_range _aq_start _aq_end
198
+ for _aq_range in $_aq_ranges; do
199
+ _aq_start="${_aq_range%%-*}"
200
+ _aq_end="${_aq_range##*-}"
201
+ if [ "$_aq_line" -ge "$_aq_start" ] 2>/dev/null && [ "$_aq_line" -le "$_aq_end" ] 2>/dev/null; then
202
+ return 0
203
+ fi
204
+ done
205
+ return 1
206
+ }
207
+
208
+ # Emit "start-end" for each added hunk in the staged diff of $1.
209
+ # Empty output means the file has no added lines at all, in which case every finding
210
+ # is pre-existing by construction.
211
+ staged_added_line_ranges() {
212
+ local _aq_file="$1"
213
+ git diff --cached -U0 -- "$_aq_file" 2>/dev/null \
214
+ | awk '
215
+ /^@@/ {
216
+ # @@ -a,b +c,d @@ -- the +side is the new-file line numbering.
217
+ if (match($0, /\+[0-9]+(,[0-9]+)?/)) {
218
+ spec = substr($0, RSTART + 1, RLENGTH - 1)
219
+ n = split(spec, parts, ",")
220
+ start = parts[1] + 0
221
+ count = (n > 1) ? parts[2] + 0 : 1
222
+ if (count > 0) printf "%d-%d ", start, start + count - 1
223
+ }
224
+ }
225
+ ' || true
226
+ }
227
+
181
228
  # Anti-patterns 3-5 (WARNING tier): weak-only assertions, mock-echo assertions, and
182
229
  # call-order over-verification. These can be legitimate, so each is reported but
183
230
  # never blocks.
package/hooks/pre-commit CHANGED
@@ -342,7 +342,9 @@ file_exists_in_subdir() {
342
342
  # in every gate, so their definitions lead the first call.
343
343
  # ============================================================================
344
344
  AUDIT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
345
- PROJECT_ROOT_FOR_AUDIT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$AUDIT_SCRIPT_DIR/../..")"
345
+ # PROJECT_ROOT is resolved once above (line ~235); re-running `git rev-parse` here
346
+ # costs another ~90ms fork on every commit for the same answer (#640).
347
+ PROJECT_ROOT_FOR_AUDIT="${PROJECT_ROOT:-$(cd "$AUDIT_SCRIPT_DIR/../.." 2>/dev/null && pwd)}"
346
348
 
347
349
  # Audit attribution safety net: if a previous commit's post-commit never fired
348
350
  # (hooks skipped, crash, or an earlier --no-verify), its staged entries are
@@ -759,6 +761,10 @@ if [ "$is_protected" = "true" ]; then
759
761
  else
760
762
  # Check if staged changes include VERSION or CHANGELOG.md
761
763
  STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
764
+ # #640: a fork costs ~90ms on Windows (measured: 100 `git rev-parse` = 9456ms),
765
+ # and this exact query is re-run by later gates. Cache it once; consumers below
766
+ # read $XP_STAGED_ACM. Subprocess COUNT, not scan duration, is the bottleneck.
767
+ XP_STAGED_ACM="$STAGED_FILES"
762
768
  for file in $STAGED_FILES; do
763
769
  case "$file" in
764
770
  VERSION) has_version_change=true ;;
@@ -918,7 +924,7 @@ echo ""
918
924
  # scope rationale as the mirror exclusion (#507 Delphi round-2 follow-up).
919
925
  MIRROR_PATH_FILTER='^src/npm-package/'
920
926
  DASHBOARD_PATH_FILTER='^dashboard/'
921
- RAW_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
927
+ RAW_CHANGED_FILES="${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM)}"
922
928
 
923
929
  # Dependencies and build output are not project code (#649).
924
930
  #
@@ -2345,7 +2351,7 @@ else
2345
2351
  GATE_5_SKIP_COUNT=0
2346
2352
  GATE_5_DELETED_TESTS=""
2347
2353
  if declare -f classify_test_focus >/dev/null 2>&1; then
2348
- STAGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
2354
+ STAGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
2349
2355
  | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|cs|scala|ps1)$|(^|/)(test_[^/]*\.py|[^/]*_test\.(py|go))$' || true)
2350
2356
  if [ -n "$STAGED_TEST_FILES" ]; then
2351
2357
  while IFS= read -r _tf; do
@@ -2395,17 +2401,63 @@ else
2395
2401
  _tauto=$(printf '%s\n' "$_tf_content" | classify_tautological_assertions || true)
2396
2402
  _swallow=$(printf '%s\n' "$_tf_content" | classify_swallowed_errors || true)
2397
2403
  _weak=$(printf '%s\n' "$_tf_content" | classify_weak_assertions || true)
2404
+ # #668: split the findings into "added by this change" and
2405
+ # "pre-existing". Gate 5d examines whole files, so editing a file
2406
+ # anywhere re-examines all of its assertions; presenting standing debt
2407
+ # under an unrelated commit is a false attribution, and false
2408
+ # attribution is what drives people to @assertion-justified instead of
2409
+ # a real fix.
2410
+ _new_ranges=$(staged_added_line_ranges "$_tf" 2>/dev/null || true)
2398
2411
  if [ -n "$_tauto" ]; then
2399
- echo "❌ BLOCKED - tautological assertion(s) in $_tf (cannot fail):"
2400
- printf '%s\n' "$_tauto" | sed 's/^/ /'
2401
- echo " See skills/test-driven-development/testing-anti-patterns.md"
2402
- GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2412
+ _tauto_new=""
2413
+ _tauto_old=""
2414
+ while IFS= read -r _tline; do
2415
+ [ -n "$_tline" ] || continue
2416
+ _tnum="${_tline%%:*}"
2417
+ if assertion_line_is_new "$_tf" "$_tnum" "$_new_ranges"; then
2418
+ _tauto_new="${_tauto_new}${_tline}"$'\n'
2419
+ else
2420
+ _tauto_old="${_tauto_old}${_tline}"$'\n'
2421
+ fi
2422
+ done <<< "$_tauto"
2423
+ if [ -n "$_tauto_new" ]; then
2424
+ echo "❌ BLOCKED - tautological assertion(s) added by this change in $_tf (cannot fail):"
2425
+ printf '%s' "$_tauto_new" | sed 's/^/ /'
2426
+ echo " See skills/test-driven-development/testing-anti-patterns.md"
2427
+ GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2428
+ fi
2429
+ if [ -n "$_tauto_old" ]; then
2430
+ echo "⚠️ WARN - PRE-EXISTING tautological assertion(s) in $_tf (NOT introduced by this change):"
2431
+ printf '%s' "$_tauto_old" | sed 's/^/ /'
2432
+ echo " These predate this commit. Two options:"
2433
+ echo " fix them now (see 'xp-gate audit assertions'), or"
2434
+ echo " leave them - they are not blocking you."
2435
+ fi
2403
2436
  fi
2404
2437
  if [ -n "$_swallow" ]; then
2405
- echo "❌ BLOCKED - empty catch in $_tf swallows the failure it guards:"
2406
- printf '%s\n' "$_swallow" | sed 's/^/ /'
2407
- echo " See skills/test-driven-development/testing-anti-patterns.md"
2408
- GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2438
+ # Same #668 split as the tautology branch above.
2439
+ _sw_new=""
2440
+ _sw_old=""
2441
+ while IFS= read -r _sline; do
2442
+ [ -n "$_sline" ] || continue
2443
+ _snum="${_sline%%:*}"
2444
+ if assertion_line_is_new "$_tf" "$_snum" "$_new_ranges"; then
2445
+ _sw_new="${_sw_new}${_sline}"$'\n'
2446
+ else
2447
+ _sw_old="${_sw_old}${_sline}"$'\n'
2448
+ fi
2449
+ done <<< "$_swallow"
2450
+ if [ -n "$_sw_new" ]; then
2451
+ echo "❌ BLOCKED - empty catch added by this change in $_tf swallows the failure it guards:"
2452
+ printf '%s' "$_sw_new" | sed 's/^/ /'
2453
+ echo " See skills/test-driven-development/testing-anti-patterns.md"
2454
+ GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2455
+ fi
2456
+ if [ -n "$_sw_old" ]; then
2457
+ echo "⚠️ WARN - PRE-EXISTING empty catch(es) in $_tf (NOT introduced by this change):"
2458
+ printf '%s' "$_sw_old" | sed 's/^/ /'
2459
+ echo " These predate this commit; see 'xp-gate audit assertions'."
2460
+ fi
2409
2461
  fi
2410
2462
  if [ -n "$_weak" ]; then
2411
2463
  echo "⚠️ WARN - test block(s) in $_tf assert only weak properties:"
@@ -2705,7 +2757,7 @@ else
2705
2757
  # Gate 5b: Mock Density ADVISORY Scan (pure bash — no npx tsx overhead)
2706
2758
  # 30% = ADVISORY, 50% = suggests @mock-justified. Does NOT block commit.
2707
2759
  # ========================================================================
2708
- CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | filter_project_paths | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
2760
+ CHANGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM)}" | filter_project_paths | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
2709
2761
 
2710
2762
  # Nested plugin sub-projects (plugins/<name>/) own their own vitest config and
2711
2763
  # are excluded from the root suite (`vitest.config.ts` excludes `plugins/**`,
@@ -2853,7 +2905,7 @@ else
2853
2905
  CHANGED_TEST_FILE_COUNT=$(echo "$SELECTED_TEST_FILES" | grep -c '.' || true)
2854
2906
 
2855
2907
  # Also get changed source files (non-test) for related-test lookup
2856
- CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM | filter_project_paths | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
2908
+ CHANGED_SRC_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM)}" | filter_project_paths | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
2857
2909
  # Nested plugin sub-projects are excluded from the root suite; their
2858
2910
  # sources must not drive root-vitest related-test lookup either (it
2859
2911
  # would resolve to plugin-local paths vitest cannot collect).
@@ -3088,16 +3140,16 @@ else
3088
3140
  # partial so Stage 2 warns instead of falsely blocking.
3089
3141
  # full -> unchanged behavior: whole suite + real threshold.
3090
3142
  # skip -> nothing test-relevant changed; no pytest at all.
3091
- PY_CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
3143
+ PY_CHANGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
3092
3144
  | filter_project_paths \
3093
3145
  | grep -E '(^|/)test_[^/]*\.py$|_test\.py$' || true)
3094
- PY_CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
3146
+ PY_CHANGED_SRC_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
3095
3147
  | filter_project_paths \
3096
3148
  | grep -E '\.py$' | grep -vE '(^|/)test_[^/]*\.py$|_test\.py$' || true)
3097
3149
  # NOT filtered on purpose: this list answers "did a requirements
3098
3150
  # file change", which is a property of the whole commit including
3099
3151
  # paths under a vendor/ tree that filter_project_paths removes.
3100
- PY_CHANGED_ALL=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)
3152
+ PY_CHANGED_ALL="${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)}"
3101
3153
  # `grep -c '.'` prints its own zero AND exits 1 on an empty list
3102
3154
  # (#493), so the count is derived with `|| true` + wc -l instead.
3103
3155
  PY_TEST_COUNT=$(printf '%s\n' "$PY_CHANGED_TEST_FILES" | grep -c '.' || true)
@@ -3844,14 +3896,21 @@ else
3844
3896
  exit 1
3845
3897
  fi
3846
3898
  echo " Running archlint for TypeScript..."
3847
- # Use diff mode with baseline for ratchet (block only new violations)
3848
- # Fall back to raw scan if no baseline exists (new project, zero-tolerance)
3849
- if [ -f "$PROJECT_ROOT/.architecture-baseline.json" ]; then
3899
+ # #640: archlint costs ~8s and scans the whole tree, but an architecture
3900
+ # violation cannot be introduced by a commit that touches no source file.
3901
+ # Gating on "did any source change" removes the single largest fixed cost
3902
+ # from docs/config-only commits without weakening the check at all.
3903
+ if ! printf '%s\n' "${XP_STAGED_ACM:-}" | grep -qE '\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|kts|rs|cpp|cc|cxx|c|h|hpp|swift|dart)$'; then
3904
+ echo " ⏭️ SKIPPED - Architecture validation (no source files changed)"
3905
+ ARCHLINT_OUTPUT=""
3906
+ ARCHLINT_EXIT=0
3907
+ elif [ -f "$PROJECT_ROOT/.architecture-baseline.json" ]; then
3850
3908
  ARCHLINT_OUTPUT=$(run_archlint diff .architecture-baseline.json --fail-on high 2>&1)
3909
+ ARCHLINT_EXIT=$?
3851
3910
  else
3852
3911
  ARCHLINT_OUTPUT=$(run_archlint scan . -f table --quiet 2>&1)
3912
+ ARCHLINT_EXIT=$?
3853
3913
  fi
3854
- ARCHLINT_EXIT=$?
3855
3914
  echo "$ARCHLINT_OUTPUT" | tail -30
3856
3915
  if [ "$ARCHLINT_EXIT" -ne 0 ]; then
3857
3916
  echo ""
@@ -4560,7 +4619,58 @@ fi
4560
4619
  # ============================================================================
4561
4620
 
4562
4621
  # ============================================================================
4563
- # GATE 13: Compiler Strictness Check
4622
+ # GATE 12b: Mirror Parity (#654)
4623
+ #
4624
+ # `githooks/**` and `plugins/*/src/**` are canonical; `src/npm-package/**` holds
4625
+ # byte-identical mirrors that get published. Editing only the mirror means the next
4626
+ # prepack silently regenerates it over the change -- v0.27.3 shipped exactly that
4627
+ # bug, and mirror/sync rework accounts for 58 failures in the session logs.
4628
+ #
4629
+ # The check used to run only at pre-push, which turns a two-second fix into a
4630
+ # five-minute wait. It runs here now, but ONLY over files in this commit: a
4631
+ # full-tree scan on every commit is the cost that pushed it to pre-push in the
4632
+ # first place.
4633
+ # ============================================================================
4634
+ MIRROR_STAGED=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)
4635
+ if [ -n "$MIRROR_STAGED" ] && printf '%s\n' "$MIRROR_STAGED" | grep -qE '^(githooks/|plugins/[^/]+/src/)'; then
4636
+ # Reuse PROJECT_ROOT: `git rev-parse` costs ~90ms per fork on Windows and this
4637
+ # gate must not add two of them to every commit (#640).
4638
+ MIRROR_REPO_ROOT="${PROJECT_ROOT:-}"
4639
+ if [ -n "$MIRROR_REPO_ROOT" ] && [ -f "$MIRROR_REPO_ROOT/scripts/check-hook-mirror.sh" ]; then
4640
+ # Scope to the touched families only: a mirror pair is clean or dirty
4641
+ # independently, and checking untouched families is the slow path this gate
4642
+ # exists to avoid.
4643
+ MIRROR_FAIL=0
4644
+ while IFS= read -r _mf; do
4645
+ [ -n "$_mf" ] || continue
4646
+ case "$_mf" in
4647
+ githooks/*) _mm="src/npm-package/hooks/${_mf#githooks/}" ;;
4648
+ plugins/*/src/*) _mm=$(printf '%s' "$_mf" | sed 's|^plugins/\([^/]*\)/src/|src/npm-package/plugins/\1/src/|') ;;
4649
+ *) continue ;;
4650
+ esac
4651
+ if [ ! -f "$MIRROR_REPO_ROOT/$_mm" ]; then
4652
+ echo "❌ BLOCKED - Gate 12b: $_mf has no mirror at $_mm"
4653
+ echo " Run: node src/npm-package/scripts/sync-package-content.js --fix"
4654
+ MIRROR_FAIL=$((MIRROR_FAIL + 1))
4655
+ elif ! cmp -s "$MIRROR_REPO_ROOT/$_mf" "$MIRROR_REPO_ROOT/$_mm"; then
4656
+ echo "❌ BLOCKED - Gate 12b: $_mm differs from canonical $_mf"
4657
+ echo " Run: node src/npm-package/scripts/sync-package-content.js --fix"
4658
+ MIRROR_FAIL=$((MIRROR_FAIL + 1))
4659
+ fi
4660
+ done <<< "$MIRROR_STAGED"
4661
+ if [ "$MIRROR_FAIL" -gt 0 ]; then
4662
+ echo ""
4663
+ echo "❌ BLOCKED - Gate 12b: $MIRROR_FAIL mirror parity violation(s)."
4664
+ echo " The mirror is what gets published; a mirror-only fix is silently"
4665
+ echo " reverted by the next prepack (v0.27.3 shipped that bug)."
4666
+ GATE_FAILED=1
4667
+ else
4668
+ echo "✅ PASSED - Gate 12b: mirror parity (touched files)"
4669
+ fi
4670
+ fi
4671
+ fi
4672
+ # ============================================================================
4673
+
4564
4674
  # The compiler is the first and cheapest static analyser for a compiled language
4565
4675
  # and XP-Gate never used it as one: cpp.sh only runs clang-tidy/cppcheck over the
4566
4676
  # first 20 files, and Gate 9's build-integrity half is TypeScript-and-Maven only.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @test REQ-668 `xp-gate audit assertions` 必须让存量断言债务可见
3
+ * @intent Gate 5d 只检查 **diff 范围内** 的文件。这是有意的——全仓扫描每次提交都跑
4
+ * 太慢——但它有个昂贵的副作用:存量债务会在 **无关改动** 时突然阻断,
5
+ * 制造错误的因果归因。原作者改了 `writeReport()` 的错误处理,却被
6
+ * `test-alignment.test.ts` 里 4 处既有的 `expect(true).toBe(true)` 拦下。
7
+ *
8
+ * 这个副作用最坏的后果不是那一次误阻断,而是它改变修复者的行为:归因噪音
9
+ * 让人以为"先绕过再说",于是倾向加 `@assertion-justified` 而不是真修掉。
10
+ * 一个让人更愿意豁免的门禁,比没有门禁更糟。
11
+ *
12
+ * 所以 `audit assertions` 的价值在于把"存量"变成可批处理的工作队列,
13
+ * 而不是让人在无关提交里逐个撞见。
14
+ * @covers AC-668-01, AC-668-02, AC-668-03
15
+ */
16
+ 'use strict';
17
+
18
+ const { scanForAssertionDebt, summarizeDebt } = require('../assertion-debt');
19
+
20
+ describe('xp-gate audit assertions (#668)', () => {
21
+ it('AC-668-01: 列出全仓存量问题,按文件分组', () => {
22
+ const results = scanForAssertionDebt({ cwd: process.cwd() });
23
+ expect(Array.isArray(results)).toBe(true);
24
+ // Grouped by file so a batch fixer can work one file at a time.
25
+ for (const entry of results) {
26
+ expect(typeof entry.file).toBe('string');
27
+ expect(Array.isArray(entry.findings)).toBe(true);
28
+ for (const f of entry.findings) {
29
+ expect(typeof f.line).toBe('number');
30
+ expect(['tautological', 'swallowed-error']).toContain(f.kind);
31
+ expect(typeof f.text).toBe('string');
32
+ }
33
+ }
34
+ });
35
+
36
+ it('AC-668-02: 识别两类确定性 vacuous 形态', () => {
37
+ // These are the two shapes Gate 5d blocks. Both are the ABSENCE of an assertion
38
+ // wearing assertion syntax, which is why they are the ones worth a debt list.
39
+ const fixture = [
40
+ 'test("a", () => {',
41
+ ' expect(true).toBe(true);',
42
+ ' try { go(); } catch {}',
43
+ '});',
44
+ 'test("b", () => {',
45
+ ' expect(x).toBe(1);',
46
+ '});',
47
+ ].join('\n');
48
+ const findings = scanForAssertionDebt({ source: fixture, file: 'fixture.test.ts' });
49
+ const flat = findings.flatMap((f) => f.findings);
50
+ expect(flat.some((f) => f.kind === 'tautological')).toBe(true);
51
+ expect(flat.some((f) => f.kind === 'swallowed-error')).toBe(true);
52
+ // A real assertion must not be reported -- a debt list that flags `expect(x).toBe(1)`
53
+ // is a list nobody finishes.
54
+ const realLine = flat.find((f) => f.text.includes('expect(x).toBe(1)'));
55
+ expect(realLine).toBeUndefined();
56
+ });
57
+
58
+ it('AC-668-03: 摘要给出可批次清零的数量,而非只是行数', () => {
59
+ const fixture = ['expect(true).toBe(true);', 'expect(1).toBe(1);', 'try { x(); } catch {}'].join('\n');
60
+ const results = scanForAssertionDebt({ source: fixture, file: 'a.test.ts' });
61
+ const summary = summarizeDebt(results);
62
+ expect(summary.files).toBe(1);
63
+ expect(summary.total).toBe(3);
64
+ // Two tautologies and one empty catch: the split matters because they are fixed
65
+ // by different edits (rewrite the assertion vs. handle the error).
66
+ expect(summary.byKind.tautological).toBe(2);
67
+ expect(summary.byKind['swallowed-error']).toBe(1);
68
+ });
69
+ });
70
+
71
+ /**
72
+ * @test REQ-668 fixture 不能被当作存量债务
73
+ * @intent 这条测试防的是一个**修债务反而删掉测试覆盖**的失败模式。
74
+ *
75
+ * 测试 Gate 5d 分类器的那批 .bats / .test.ts 文件必然包含
76
+ * `'expect(true).toBe(true);'` —— 那是被校验的**输入**,不是断言。
77
+ * 若它们被列进"去修掉"的清单,执行清单的人会删掉钉住分类器行为的字符串,
78
+ * 于是债务清单本身成了覆盖率的破坏者。
79
+ *
80
+ * 反例同样重要:真正的裸断言 `expect(true).toBe(true);` 必须仍然被报出来。
81
+ * 只做"少报"很容易,难的是**同时**不误报也不漏报。
82
+ * @covers AC-668-04
83
+ */
84
+ describe('fixture 与真实断言的区分 (#668)', () => {
85
+ it('AC-668-04: 带引号的 fixture 不报,裸断言照报', () => {
86
+ const { classifyLine } = require('../assertion-debt');
87
+ // Real code: must be reported.
88
+ expect(classifyLine('expect(true).toBe(true);')).toBe('tautological');
89
+ expect(classifyLine(' expect(1).toBe(1);')).toBe('tautological');
90
+ expect(classifyLine('try { x(); } catch {}')).toBe('swallowed-error');
91
+ // Fixture inputs: must not be reported.
92
+ expect(classifyLine("' expect(true).toBe(true);',")).toBeNull();
93
+ expect(classifyLine('const s = "expect(true).toBe(true)";')).toBeNull();
94
+ // Neither is debt.
95
+ expect(classifyLine('// expect(true).toBe(true);')).toBeNull();
96
+ expect(classifyLine('expect(x).toBe(1);')).toBeNull();
97
+ expect(classifyLine('expect(value).toBeTruthy();')).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @test REQ-652 BUILD 阶段的门禁清单必须从真相源生成,而非手抄
3
+ * @intent #652 的实证是:BUILD 阶段生成代码时,模型对 Boy Scout / CCN / jscpd /
4
+ * archlint 完全不可知——不是"知道却不遵守",是**根本不知道规则存在**。
5
+ * 后果是 27% 的门禁失败属于静态规则违规,即唯一 100% 可在生成前规避的类别。
6
+ *
7
+ * 补一页手写的阈值表能解决今天的问题,但会制造明天的问题:`.principlesrc`
8
+ * 里 `long-function` 是 50、`deep-nesting` 是 4,一旦有人调成 60/5,那一页
9
+ * 手抄的表就开始说谎。**一个说谎的自检清单比没有清单更糟**——它会让人按错的
10
+ * 规则写代码,然后在提交时被告知另一套。
11
+ *
12
+ * 所以这个清单必须从配置文件生成。真相源有几个就在几个里读,不复制。
13
+ * @covers AC-652-01, AC-652-02, AC-652-03
14
+ */
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const { collectGateRules } = require('../gate-rules');
20
+
21
+ const REPO = path.join(__dirname, '..', '..', '..', '..');
22
+
23
+ describe('BUILD 阶段门禁清单 (#652)', () => {
24
+ it('AC-652-01: 从 .principlesrc 读出真实阈值,不是写死的数字', () => {
25
+ const rules = collectGateRules({ cwd: REPO });
26
+ const longFn = rules.find((r) => r.id === 'clean-code.long-function');
27
+ const nesting = rules.find((r) => r.id === 'clean-code.deep-nesting');
28
+ expect(longFn).toBeDefined();
29
+ expect(nesting).toBeDefined();
30
+ // The value must come from the config file. Reading .principlesrc here keeps the
31
+ // test honest: if the config changes and the generator does not follow, this fails.
32
+ const cfg = JSON.parse(fs.readFileSync(path.join(REPO, '.principlesrc'), 'utf8'));
33
+ expect(longFn.threshold).toBe(cfg.rules['clean-code']['long-function'].threshold);
34
+ expect(nesting.threshold).toBe(cfg.rules['clean-code']['deep-nesting'].threshold);
35
+ });
36
+
37
+ it('AC-652-02: 覆盖会阻断提交的每一类规则,而不只是 principles', () => {
38
+ const rules = collectGateRules({ cwd: REPO });
39
+ const sources = new Set(rules.map((r) => r.source));
40
+ // #652 named exactly these four as absent from BUILD docs.
41
+ for (const required of ['principles', 'ccn', 'jscpd', 'boy-scout']) {
42
+ expect(sources).toContain(required);
43
+ }
44
+ });
45
+
46
+ it('AC-652-03: 生成结果可渲染成一份自检清单,含阈值与出处', () => {
47
+ const rules = collectGateRules({ cwd: REPO });
48
+ for (const rule of rules) {
49
+ expect(typeof rule.id).toBe('string');
50
+ expect(typeof rule.source).toBe('string');
51
+ // Without the evidence pointer, "the threshold is 50" cannot be checked or
52
+ // argued with -- it is indistinguishable from a guess.
53
+ expect(typeof rule.evidence).toBe('string');
54
+ expect(rule.evidence).not.toBe('');
55
+ expect(['block', 'warn', 'info']).toContain(rule.severity);
56
+ }
57
+ });
58
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @test REQ-654 镜像清单必须自证完整:漏登记的规范源文件要报错,而不是静默不同步
3
+ * @intent 会话日志里 `mirror/sync` 类失败 **58 次**,git 历史反复出现
4
+ * `fix(mirror): ...` / `fix(package): 把 java.sh 改动落到真正的源文件`。
5
+ *
6
+ * 根因不是模型不知道规则——`AGENTS.md` 写得很清楚——而是这类约束是
7
+ * **结构性信息**:哪个文件是源、哪个是镜像、谁依赖谁。模型即使知道规则,
8
+ * 也要在多文件编辑中持续追踪映射关系,而这正是工具该做的事。
9
+ *
10
+ * 提示词救不了的部分,必须靠**漏登记即报错**来兜底。静默不同步是最坏的形态:
11
+ * 它让一次疏忽变成一次发布回滚(v0.27.3 正是只改镜像导致发布回滚)。
12
+ * @covers AC-654-01, AC-654-02, AC-654-03
13
+ */
14
+ 'use strict';
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+ const { findUnlistedSources, declareSources } = require('../mirror-manifest');
19
+
20
+ const REPO = path.join(__dirname, '..', '..', '..', '..');
21
+
22
+ describe('镜像清单完整性 (#654)', () => {
23
+ it('AC-654-01: 列出 canonical 目录下所有应被镜像的文件', () => {
24
+ const declared = declareSources({ cwd: REPO });
25
+ expect(Array.isArray(declared)).toBe(true);
26
+ // A manifest that silently returns nothing would pass every other assertion here,
27
+ // so pin the shape: real file lists are non-trivial.
28
+ expect(declared.length).toBeGreaterThan(20);
29
+ for (const entry of declared) {
30
+ expect(typeof entry.source).toBe('string');
31
+ expect(typeof entry.mirror).toBe('string');
32
+ // Both paths are repo-relative, forward-slashed, so drift messages are readable.
33
+ expect(entry.source.includes('\\')).toBe(false);
34
+ expect(entry.mirror.includes('\\')).toBe(false);
35
+ }
36
+ });
37
+
38
+ it('AC-654-02: 实际存在的规范源文件若未登记,必须被报出来', () => {
39
+ const unlisted = findUnlistedSources({ cwd: REPO });
40
+ // The invariant the issue asks for: zero unlisted sources. This is the assertion
41
+ // that turns "someone forgot to register java.sh" from a publish-time rollback
42
+ // into a red test at commit time.
43
+ expect(unlisted).toEqual([]);
44
+ });
45
+
46
+ it('AC-654-03: 合成一个未登记文件时能被检出(防止检测器本身失效)', () => {
47
+ // A detector that never fires is indistinguishable from "no drift". Prove it fires
48
+ // on a synthetic tree rather than trusting the empty result above.
49
+ const fixture = {
50
+ 'githooks/pre-commit': 'x',
51
+ 'githooks/lib/known.sh': 'x',
52
+ 'githooks/lib/orphan.sh': 'x',
53
+ };
54
+ const unlisted = findUnlistedSources({
55
+ cwd: REPO,
56
+ files: Object.keys(fixture),
57
+ declared: [
58
+ { source: 'githooks/pre-commit', mirror: 'src/npm-package/hooks/pre-commit' },
59
+ { source: 'githooks/lib/known.sh', mirror: 'src/npm-package/hooks/lib/known.sh' },
60
+ ],
61
+ });
62
+ expect(unlisted).toEqual(['githooks/lib/orphan.sh']);
63
+ });
64
+ });