@boyingliu01/xp-gate 0.32.1 → 0.32.3

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 (69) hide show
  1. package/adapters/gate-3.sh +483 -28
  2. package/adapters/gate-4.sh +20 -0
  3. package/adapters/gate-7.sh +4 -0
  4. package/adapters/powershell.sh +33 -7
  5. package/bin/xp-gate.js +48 -2
  6. package/gate-3.sh +37 -2
  7. package/gate-4.sh +8 -0
  8. package/hooks/gate-3.sh +37 -2
  9. package/hooks/gate-4.sh +8 -0
  10. package/hooks/post-commit +82 -0
  11. package/hooks/pre-commit +19 -0
  12. package/hooks/pre-push +21 -2
  13. package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
  14. package/lib/__tests__/gate-audit.test.ts +144 -0
  15. package/lib/__tests__/shared-utils.test.js +22 -0
  16. package/lib/doctor.js +59 -1
  17. package/lib/gate-audit.ts +215 -7
  18. package/lib/mattpocock-license-verdict.json +1 -1
  19. package/lib/shared-utils.js +4 -1
  20. package/mock-policy/AGENTS.md +3 -3
  21. package/mutation/AGENTS.md +3 -3
  22. package/package.json +1 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  25. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +21 -0
  27. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  28. package/plugins/dsh/README.md +51 -2
  29. package/plugins/dsh/client/client.js +760 -0
  30. package/plugins/dsh/lib/client.js +760 -0
  31. package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
  32. package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
  33. package/plugins/dsh/lib/delphi-settings.js +76 -0
  34. package/plugins/dsh/lib/hooks.d.ts +14 -1
  35. package/plugins/dsh/lib/hooks.d.ts.map +1 -1
  36. package/plugins/dsh/lib/hooks.js +44 -5
  37. package/plugins/dsh/lib/index.d.ts +63 -0
  38. package/plugins/dsh/lib/index.d.ts.map +1 -1
  39. package/plugins/dsh/lib/index.js +126 -21
  40. package/plugins/dsh/package.json +13 -3
  41. package/plugins/dsh/scripts/copy-client.cjs +37 -0
  42. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  43. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  44. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +21 -0
  45. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/plugins/dsh/src/client.test.ts +231 -0
  47. package/plugins/dsh/src/contract.test.ts +25 -1
  48. package/plugins/dsh/src/delphi-settings.test.ts +101 -0
  49. package/plugins/dsh/src/delphi-settings.ts +111 -0
  50. package/plugins/dsh/src/hooks.test.ts +48 -0
  51. package/plugins/dsh/src/hooks.ts +45 -5
  52. package/plugins/dsh/src/index.test.ts +3 -2
  53. package/plugins/dsh/src/index.ts +147 -25
  54. package/plugins/opencode/package.json +1 -1
  55. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  56. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  57. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +21 -0
  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/references/phase-3-build.md +21 -0
  63. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  64. package/principles/AGENTS.md +3 -3
  65. package/scripts/sync-package-content.js +1 -1
  66. package/skills/delphi-review/AGENTS.md +3 -3
  67. package/skills/sprint-flow/AGENTS.md +3 -3
  68. package/skills/sprint-flow/references/phase-3-build.md +21 -0
  69. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -268,6 +268,19 @@ run_static_analysis() {
268
268
  return 3
269
269
  fi
270
270
  fi
271
+ # Downstream hardening (ported): a scope file narrows the gate to product
272
+ # code, but it must be analysed under a DECLARED rule set. With no
273
+ # PSScriptAnalyzerSettings.psd1 the -Settings binding falls back to defaults,
274
+ # so a repo that declared its scope silently swapped rule sets. Refuse the
275
+ # run rather than grade against an unknown rule set.
276
+ #
277
+ # Mirrors the missing-module semantics (return 3 = fail closed upstream).
278
+ if [ "${#analysis_targets[@]}" -gt 0 ] && [ ! -f "PSScriptAnalyzerSettings.psd1" ]; then
279
+ echo "XP_G1_REASON: analysis-settings-missing:PSScriptAnalyzerSettings.psd1"
280
+ echo " ⚠️ $scope_file is in use but PSScriptAnalyzerSettings.psd1 is absent - refusing to analyse with an unknown rule set"
281
+ return 3
282
+ fi
283
+
271
284
 
272
285
  if [ "${#analysis_targets[@]}" -gt 0 ]; then
273
286
  # Scoped per-path run. -Path binds ONE string only (an array argument
@@ -430,14 +443,27 @@ run_lint() {
430
443
  # A hanging Pester test (e.g. a test that dot-sources a script whose body performs
431
444
  # WMI/registry/scheduled-task scans) would otherwise block `git commit` forever.
432
445
  # Default 300s, override via XP_GATE_POWERSHELL_TEST_TIMEOUT_S.
446
+ # #599: ONE helper, TWO budgets. The instrumented coverage run enables
447
+ # CodeCoverage, so instrumentation alone costs more than a plain run: a shared
448
+ # 300s budget is not the same headroom for both. The caller passes its default
449
+ # explicitly and the timeout message names the variable that actually governs it,
450
+ # otherwise a budget shortfall gets reported as "a test is hanging" and the
451
+ # reader is sent to the wrong place.
433
452
  _pester_timeout() {
434
- local default_s="${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}"
453
+ local budget_var="$1"
454
+ local default_s="${2:-300}"
455
+ local effective_s
456
+ if [ "$budget_var" = "XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S" ]; then
457
+ effective_s="${XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S:-$default_s}"
458
+ else
459
+ effective_s="${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-$default_s}"
460
+ fi
435
461
  # Prefer GNU timeout (Git Bash / Linux). On Windows where `timeout` resolves to
436
462
  # the native timeout.exe, fall back to running without a hard timeout.
437
463
  if command -v timeout >/dev/null 2>&1 && timeout --version 2>/dev/null | grep -qi "coreutils"; then
438
- timeout "${default_s}s" "$@"
464
+ timeout "${effective_s}s" "${@:3}"
439
465
  else
440
- "$@"
466
+ "${@:3}"
441
467
  fi
442
468
  }
443
469
 
@@ -465,7 +491,7 @@ run_tests() {
465
491
  if [ "${#test_files[@]}" -gt 0 ]; then
466
492
  test_paths=$(_powershell_path_array "${test_files[@]}")
467
493
  echo "Running Pester tests..."
468
- _pester_timeout "$PWSH" "${POWERSHELL_ARGS[@]}" "
494
+ _pester_timeout "XP_GATE_POWERSHELL_TEST_TIMEOUT_S" "${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}" "$PWSH" "${POWERSHELL_ARGS[@]}" "
469
495
  \$results = Invoke-Pester -Path $test_paths -PassThru
470
496
  if (\$results.FailedCount -gt 0) {
471
497
  Write-Host \"FAILED: \$(\$results.FailedCount) test(s)\"
@@ -476,7 +502,7 @@ run_tests() {
476
502
  "
477
503
  local run_exit=$?
478
504
  if [ "$run_exit" = "124" ]; then
479
- echo "⚠️ Pester tests TIMED OUT after ${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}s (may be hanging)."
505
+ echo "⚠️ Pester tests TIMED OUT after ${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}s (XP_GATE_POWERSHELL_TEST_TIMEOUT_S; may be hanging)."
480
506
  echo " A test may be blocking on a system call (WMI/registry/scheduled-task)."
481
507
  echo " Fix the hanging test, or set XP_GATE_POWERSHELL_TEST_TIMEOUT_S to a higher value."
482
508
  return 1
@@ -572,7 +598,7 @@ run_coverage() {
572
598
  # is the deprecated Pester 4 parameter set and fails outright on Pester 5.x
573
599
  # ("cannot resolve parameter set"). Using it silently produced no coverage.xml,
574
600
  # which the pre-commit hook then reported as an unparseable/zero result.
575
- _pester_timeout "$PWSH" "${POWERSHELL_ARGS[@]}" "
601
+ _pester_timeout "XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S" "${XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S:-900}" "$PWSH" "${POWERSHELL_ARGS[@]}" "
576
602
  \$cfg = New-PesterConfiguration
577
603
  \$cfg.Run.Path = $test_paths
578
604
  \$cfg.CodeCoverage.Enabled = \$true
@@ -587,7 +613,7 @@ run_coverage() {
587
613
  " 2>&1
588
614
  local cov_exit=$?
589
615
  if [ "$cov_exit" = "124" ]; then
590
- echo "⚠️ Pester coverage TIMED OUT after ${XP_GATE_POWERSHELL_TEST_TIMEOUT_S:-300}s (may be hanging)."
616
+ echo "⚠️ Pester coverage TIMED OUT after ${XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S:-900}s (XP_GATE_POWERSHELL_COVERAGE_TIMEOUT_S; instrumentation costs more than a plain run - raise this rather than suspecting a hang)."
591
617
  return 1
592
618
  fi
593
619
  return $cov_exit
package/bin/xp-gate.js CHANGED
@@ -545,19 +545,59 @@ function handleAuditTail(args, auditPath) {
545
545
 
546
546
  function handleAuditStats(auditPath) {
547
547
  try {
548
- const { computeStats } = require(auditPath);
548
+ const { computeStats, computeAnomalies } = require(auditPath);
549
549
  const stats = computeStats();
550
550
  if (stats.length === 0) {
551
551
  console.log('No audit data found.');
552
552
  return;
553
553
  }
554
554
  printStatsTable(stats);
555
+ printAnomalies(computeAnomalies());
555
556
  } catch (err) {
556
557
  console.error('Error computing stats:', err.message);
557
558
  process.exit(1);
558
559
  }
559
560
  }
560
561
 
562
+ /**
563
+ * Print the audit-health summary. `total` is the ledger-integrity number CI
564
+ * gates on (xp-gate audit --check); the components explain where it comes from.
565
+ */
566
+ function printAnomalies(a, since) {
567
+ console.log('');
568
+ console.log('Audit health (anomalies):');
569
+ if (since) console.log(` scope : entries since ${since}`);
570
+ console.log(` entries scanned : ${a.scanned}`);
571
+ console.log(` implausible duration : ${a.implausible_duration} (epoch/negative — the REQ-B bug)`);
572
+ console.log(` missing commit : ${a.missing_commit} (blank/unknown attribution — the off-by-one bug)`);
573
+ console.log(` duration_anomaly flag: ${a.flagged}`);
574
+ console.log(` null + reason : ${a.null_with_reason} (honest unmeasurable, NOT counted as anomaly)`);
575
+ console.log(` ── total anomalies : ${a.total}`);
576
+ }
577
+
578
+ /**
579
+ * CI gate: exit 1 when the audit ledger carries anomalies (implausible
580
+ * durations or unattributed commits). This turns both telemetry bugs into a
581
+ * build failure instead of something a human has to notice in a chart.
582
+ */
583
+ function handleAuditCheck(auditPath) {
584
+ try {
585
+ const { computeAnomalies, AUDIT_ANOMALY_SINCE } = require(auditPath);
586
+ // Scope to entries after the fix: historical anomalies are grandfathered
587
+ // (they cannot be re-measured). A future epoch/blank row fails the build.
588
+ const a = computeAnomalies(process.cwd(), AUDIT_ANOMALY_SINCE);
589
+ printAnomalies(a, AUDIT_ANOMALY_SINCE);
590
+ if (a.total > 0) {
591
+ console.error(`FAIL: ${a.total} audit anomal${a.total === 1 ? 'y' : 'ies'} found since ${AUDIT_ANOMALY_SINCE} (must be 0).`);
592
+ process.exit(1);
593
+ }
594
+ console.log('OK: no new audit anomalies.');
595
+ } catch (err) {
596
+ console.error('Error checking audit health:', err.message);
597
+ process.exit(1);
598
+ }
599
+ }
600
+
561
601
  function handleAuditRecord(args, auditPath) {
562
602
  try {
563
603
  const { appendAuditEntry } = require(auditPath);
@@ -600,6 +640,11 @@ function handleAudit(args) {
600
640
  return;
601
641
  }
602
642
 
643
+ if (args.includes('--check')) {
644
+ handleAuditCheck(auditPath);
645
+ return;
646
+ }
647
+
603
648
  if (args[0] === 'record') {
604
649
  handleAuditRecord(args, auditPath);
605
650
  return;
@@ -609,7 +654,8 @@ function handleAudit(args) {
609
654
  console.error('Usage:');
610
655
  console.error(' xp-gate audit record --gate-id X --gate-name Y --passed true/false --issues-found N --duration-ms N --trigger commit');
611
656
  console.error(' xp-gate audit --tail [N] (default: 20)');
612
- console.error(' xp-gate audit --stats');
657
+ console.error(' xp-gate audit --stats (per-gate stats + anomaly summary)');
658
+ console.error(' xp-gate audit --check (exit 1 if the ledger has anomalies)');
613
659
  process.exit(1);
614
660
  }
615
661
 
package/gate-3.sh CHANGED
@@ -176,7 +176,42 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
176
176
  known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
177
177
  }
178
178
  }
179
- function norm(p) { return String(p).replace(/\\\\/g, "/").replace(/^\.\//, ""); }
179
+ // The measurement emits ABSOLUTE paths (pwsh resolved them), while
180
+ // a baseline recorded by a repo-side generator holds paths relative
181
+ // to the repo root. Normalising alone cannot bridge that, so a key
182
+ // miss falls back to a suffix match on the file path: exactly one
183
+ // known entry whose normalised path ends with "<sep>measured" is the
184
+ // same file. Without this every existing function looked brand new,
185
+ // and a real regression was reported as "new function" -- the right
186
+ // verdict for the wrong reason.
187
+ //
188
+ // Backslashes are normalised with split/join, NOT -replace: this JS
189
+ // is embedded in a double-quoted bash command, where a regex escape
190
+ // loses a level and silently matches the wrong number of
191
+ // backslashes. Measured: /\\\\/g arrived as /\\/g, matched only
192
+ // doubled separators, and every Windows path kept its backslashes.
193
+ function norm(p) {
194
+ return String(p)
195
+ .split("\\").join("/")
196
+ .replace(/^[A-Za-z]:\//, "")
197
+ .replace(/^\.\//, "")
198
+ .replace(/^\//, "");
199
+ }
200
+ function lookup(path, name) {
201
+ const key = norm(path) + "::" + name;
202
+ if (known.has(key)) return known.get(key);
203
+ const target = norm(path);
204
+ let hit = null;
205
+ for (const [k, v] of known) {
206
+ const [kp, kn] = k.split("::");
207
+ if (kn !== name) continue;
208
+ if (target.endsWith("/" + kp) || kp.endsWith("/" + target) || kp === target) {
209
+ if (hit !== null && hit !== v) return null; // ambiguous: treat as new
210
+ hit = v;
211
+ }
212
+ }
213
+ return hit;
214
+ }
180
215
  const seen = new Set();
181
216
  const regressions = [];
182
217
  const improved = [];
@@ -186,7 +221,7 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
186
221
  const key = norm(m[1]) + "::" + m[3];
187
222
  const now = Number(m[4]);
188
223
  seen.add(key);
189
- const was = known.has(key) ? known.get(key) : null;
224
+ const was = lookup(m[1], m[3]);
190
225
  if (was === null) {
191
226
  if (now > threshold) {
192
227
  regressions.push(m[1] + ":" + m[2] + " " + m[3] + " new function at ccn=" + now);
package/gate-4.sh CHANGED
@@ -107,6 +107,14 @@ else
107
107
  if [ -n "$PRINCIPLES_DIR" ]; then
108
108
  echo "Checking Clean Code + SOLID principles..."
109
109
 
110
+ # #581/#582 class: run_tsx drives a NATIVE node/tsx binary. A POSIX
111
+ # path like $HOME/.config/... reaches it as the wrong drive prefix and
112
+ # the checker is "not found", which the exit>=2 branch then reported
113
+ # as SKIPPED. Convert once here so both invocations agree.
114
+ if declare -F to_native_path >/dev/null 2>&1; then
115
+ PRINCIPLES_DIR="$(to_native_path "$PRINCIPLES_DIR")"
116
+ fi
117
+
110
118
  # AC-507-04-02: say what a Java commit is actually covered for, instead
111
119
  # of implying a full 15-rule check (#507). Printed before the run so it
112
120
  # appears on both the PASS and the FAIL path.
package/hooks/gate-3.sh CHANGED
@@ -176,7 +176,42 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
176
176
  known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
177
177
  }
178
178
  }
179
- function norm(p) { return String(p).replace(/\\\\/g, "/").replace(/^\.\//, ""); }
179
+ // The measurement emits ABSOLUTE paths (pwsh resolved them), while
180
+ // a baseline recorded by a repo-side generator holds paths relative
181
+ // to the repo root. Normalising alone cannot bridge that, so a key
182
+ // miss falls back to a suffix match on the file path: exactly one
183
+ // known entry whose normalised path ends with "<sep>measured" is the
184
+ // same file. Without this every existing function looked brand new,
185
+ // and a real regression was reported as "new function" -- the right
186
+ // verdict for the wrong reason.
187
+ //
188
+ // Backslashes are normalised with split/join, NOT -replace: this JS
189
+ // is embedded in a double-quoted bash command, where a regex escape
190
+ // loses a level and silently matches the wrong number of
191
+ // backslashes. Measured: /\\\\/g arrived as /\\/g, matched only
192
+ // doubled separators, and every Windows path kept its backslashes.
193
+ function norm(p) {
194
+ return String(p)
195
+ .split("\\").join("/")
196
+ .replace(/^[A-Za-z]:\//, "")
197
+ .replace(/^\.\//, "")
198
+ .replace(/^\//, "");
199
+ }
200
+ function lookup(path, name) {
201
+ const key = norm(path) + "::" + name;
202
+ if (known.has(key)) return known.get(key);
203
+ const target = norm(path);
204
+ let hit = null;
205
+ for (const [k, v] of known) {
206
+ const [kp, kn] = k.split("::");
207
+ if (kn !== name) continue;
208
+ if (target.endsWith("/" + kp) || kp.endsWith("/" + target) || kp === target) {
209
+ if (hit !== null && hit !== v) return null; // ambiguous: treat as new
210
+ hit = v;
211
+ }
212
+ }
213
+ return hit;
214
+ }
180
215
  const seen = new Set();
181
216
  const regressions = [];
182
217
  const improved = [];
@@ -186,7 +221,7 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
186
221
  const key = norm(m[1]) + "::" + m[3];
187
222
  const now = Number(m[4]);
188
223
  seen.add(key);
189
- const was = known.has(key) ? known.get(key) : null;
224
+ const was = lookup(m[1], m[3]);
190
225
  if (was === null) {
191
226
  if (now > threshold) {
192
227
  regressions.push(m[1] + ":" + m[2] + " " + m[3] + " new function at ccn=" + now);
package/hooks/gate-4.sh CHANGED
@@ -107,6 +107,14 @@ else
107
107
  if [ -n "$PRINCIPLES_DIR" ]; then
108
108
  echo "Checking Clean Code + SOLID principles..."
109
109
 
110
+ # #581/#582 class: run_tsx drives a NATIVE node/tsx binary. A POSIX
111
+ # path like $HOME/.config/... reaches it as the wrong drive prefix and
112
+ # the checker is "not found", which the exit>=2 branch then reported
113
+ # as SKIPPED. Convert once here so both invocations agree.
114
+ if declare -F to_native_path >/dev/null 2>&1; then
115
+ PRINCIPLES_DIR="$(to_native_path "$PRINCIPLES_DIR")"
116
+ fi
117
+
110
118
  # AC-507-04-02: say what a Java commit is actually covered for, instead
111
119
  # of implying a full 15-rule check (#507). Printed before the run so it
112
120
  # appears on both the PASS and the FAIL path.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # XP-Gate Post-Commit Hook
4
+ #
5
+ # Two jobs, both only possible AFTER the commit object exists:
6
+ #
7
+ # 1. Audit flush (#audit-attribution). Pre-commit cannot know the commit it is
8
+ # about to create — `git rev-parse HEAD` there is the PARENT, so per-gate
9
+ # audit entries used to be filed under the previous commit (the newest
10
+ # commit looked like it "ran no gates", and per-commit battery stats mixed
11
+ # two commits' timings). Pre-commit now STAGES entries in
12
+ # .xp-gate/audit.pending.jsonl; this hook flushes them stamped with the real
13
+ # HEAD. Pre-commit also flushes leftovers at its own start as a safety net,
14
+ # so a missed post-commit never loses data.
15
+ #
16
+ # 2. Bypass detection (D8 Layer 2). Detects `git commit --no-verify`:
17
+ # - pre-commit writes .git/xp-gate-precommit-marker on a successful run
18
+ # - if the marker is absent here, pre-commit was bypassed (or crashed)
19
+ # - bypass events go to .xp-gate/bypass-audit.jsonl for later enforcement
20
+ #
21
+ # Git semantics: post-commit cannot block the commit (git limitation). It only
22
+ # records. ============================================================================
23
+
24
+ set -e
25
+
26
+ git_dir="$(git rev-parse --git-dir 2>/dev/null)"
27
+ if [ -z "$git_dir" ]; then
28
+ exit 0
29
+ fi
30
+
31
+ toplevel="$(git rev-parse --show-toplevel 2>/dev/null || true)"
32
+ head_commit="$(git rev-parse HEAD 2>/dev/null || true)"
33
+
34
+ # ── 1. Flush staged audit entries with the real commit hash ──────────────────
35
+ # Only when this repo actually carries the audit module (the xp-gate checkout).
36
+ # A consumer repo has no src/npm-package; there pre-commit's record call is a
37
+ # no-op, so nothing is ever staged and there is nothing to flush.
38
+ if [ -n "$toplevel" ] && [ -n "$head_commit" ] \
39
+ && [ -f "$toplevel/src/npm-package/lib/gate-audit.ts" ]; then
40
+ _pending="$toplevel/.xp-gate/audit.pending.jsonl"
41
+ if [ -f "$toplevel/node_modules/tsx/dist/cli.mjs" ] && command -v node >/dev/null 2>&1; then
42
+ _flush() {
43
+ node "$toplevel/node_modules/tsx/dist/cli.mjs" \
44
+ "$toplevel/src/npm-package/lib/gate-audit.ts" flush-pending \
45
+ --commit-hash "$head_commit" --report true 2>/dev/null || echo 0
46
+ }
47
+ elif command -v npx >/dev/null 2>&1; then
48
+ _flush() {
49
+ ( cd "$toplevel" && npx --no-install tsx src/npm-package/lib/gate-audit.ts \
50
+ flush-pending --commit-hash "$head_commit" --report true 2>/dev/null ) || echo 0
51
+ }
52
+ fi
53
+ # Bounded drain: the last gate's recorder is fire-and-forget (`& disown` in
54
+ # pre-commit), so post-commit can start before it has written its line. Keep
55
+ # flushing until two consecutive passes find nothing, capped so a slow node
56
+ # start never stalls the commit. Usually the first pass suffices.
57
+ _passes=0
58
+ _empty=0
59
+ while [ "$_passes" -lt 6 ] && [ "$_empty" -lt 2 ]; do
60
+ _n="$(_flush)"
61
+ _passes=$((_passes + 1))
62
+ case "$_n" in ''|*[!0-9]*) _n=0 ;; esac
63
+ if [ "$_n" -gt 0 ]; then _empty=0; else _empty=$((_empty + 1)); fi
64
+ [ "$_empty" -lt 2 ] && sleep 0.05
65
+ done
66
+ fi
67
+
68
+ # ── 2. Bypass detection ─────────────────────────────────────────────────────
69
+ marker="$git_dir/xp-gate-precommit-marker"
70
+
71
+ if [ ! -f "$marker" ]; then
72
+ if [ -n "$head_commit" ]; then
73
+ mkdir -p .xp-gate
74
+ printf '{"commit":"%s","timestamp":"%s","type":"precommit_bypass","branch":"%s"}\n' \
75
+ "$head_commit" "$(date -Iseconds)" \
76
+ "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" >> .xp-gate/bypass-audit.jsonl
77
+ fi
78
+ else
79
+ rm -f "$marker"
80
+ fi
81
+
82
+ exit 0
package/hooks/pre-commit CHANGED
@@ -344,6 +344,25 @@ file_exists_in_subdir() {
344
344
  AUDIT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
345
345
  PROJECT_ROOT_FOR_AUDIT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$AUDIT_SCRIPT_DIR/../..")"
346
346
 
347
+ # Audit attribution safety net: if a previous commit's post-commit never fired
348
+ # (hooks skipped, crash, or an earlier --no-verify), its staged entries are
349
+ # still in .xp-gate/audit.pending.jsonl. Flush them now, stamped with the commit
350
+ # they actually belong to (`git log -1`), so they land under a real commit
351
+ # instead of accumulating or being stamped with THIS run's hash later. Runs
352
+ # before any gate so the flush is never counted in this commit's timings.
353
+ # Best-effort and silent: audit must never affect the commit's outcome.
354
+ if [ -f "$PROJECT_ROOT_FOR_AUDIT/.xp-gate/audit.pending.jsonl" ] \
355
+ && [ -f "$PROJECT_ROOT_FOR_AUDIT/src/npm-package/lib/gate-audit.ts" ]; then
356
+ _audit_prev_hash="$(git log -1 --format=%H 2>/dev/null || true)"
357
+ if [ -n "$_audit_prev_hash" ] && [ -f "$PROJECT_ROOT_FOR_AUDIT/node_modules/tsx/dist/cli.mjs" ] \
358
+ && command -v node >/dev/null 2>&1; then
359
+ node "$PROJECT_ROOT_FOR_AUDIT/node_modules/tsx/dist/cli.mjs" \
360
+ "$PROJECT_ROOT_FOR_AUDIT/src/npm-package/lib/gate-audit.ts" flush-pending \
361
+ --commit-hash "$_audit_prev_hash" >/dev/null 2>&1 || true
362
+ fi
363
+ unset _audit_prev_hash
364
+ fi
365
+
347
366
  # Issue #535: a failed `source` used to print one stderr line and keep going,
348
367
  # so a missing lib silently removed gate logic from the run while every gate
349
368
  # still reported PASS. Fail closed instead: abort with FATAL when the file is
package/hooks/pre-push CHANGED
@@ -184,7 +184,20 @@ write_pre_push_report() {
184
184
  printf '%s\n' "Path('$report_file').write_text(json.dumps(report, indent=2) + '\n')"
185
185
  } > "$py_script"
186
186
 
187
- "$py_bin" "$py_script"
187
+ # Native Python cannot open mktemp's POSIX path (/tmp/...) on Windows: it
188
+ # resolves the leading slash against the current drive and reports a file
189
+ # that "does not exist", which the caller then billed as "Gate M journal
190
+ # missing" on every push. Convert when a converter is available.
191
+ local py_script_native="$py_script"
192
+ if command -v cygpath >/dev/null 2>&1; then
193
+ # MSYS/Git-Bash: mktemp hands back /tmp/..., which is a MOUNT, not /<drive>/.
194
+ # Native python.exe resolves the leading slash against the current drive and
195
+ # reports "can't open file", which the caller then billed as "Gate M journal
196
+ # missing" on every push. cygpath knows the mount table; the helper library
197
+ # is not sourced yet at this point, so it cannot be used here.
198
+ py_script_native="$(cygpath -w "$py_script" 2>/dev/null || printf '%s' "$py_script")"
199
+ fi
200
+ "$py_bin" "$py_script_native"
188
201
  py_rc=$?
189
202
  rm -f "$py_script"
190
203
  if [ "$py_rc" -ne 0 ]; then
@@ -197,7 +210,13 @@ write_pre_push_report() {
197
210
  # rule can be exercised directly by tests instead of being asserted through a
198
211
  # copied-and-pasted implementation. It refuses to emit a number when the gates
199
212
  # that ran cannot summarise the report (none ran, or fewer than half did).
200
- if ! "$py_bin" "$score_lib" --report "$report_file"; then
213
+ # Same MSYS/native-path boundary as the journal body above: $score_lib is a
214
+ # POSIX path ($HOME/.config/...) and native python.exe cannot open it.
215
+ local score_lib_native="$score_lib"
216
+ if command -v cygpath >/dev/null 2>&1; then
217
+ score_lib_native="$(cygpath -w "$score_lib" 2>/dev/null || printf '%s' "$score_lib")"
218
+ fi
219
+ if ! "$py_bin" "$score_lib_native" --report "$report_file"; then
201
220
  echo "⚠️ Gate M journal: could not score $report_file — score left unset." >&2
202
221
  fi
203
222
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @test REQ-AUDIT-ATTRIBUTION xp-gate doctor surfaces gate-telemetry anomalies
3
+ * @intent The two audit bugs (epoch-sized durations, commits attributed to their
4
+ * parent) leave fingerprints in .xp-gate/audit.jsonl. CI cannot see a
5
+ * gitignored ledger, so doctor must warn locally on NEW anomalies while
6
+ * grandfathering the historical rows that cannot be re-measured.
7
+ * @covers AUDIT-001-11
8
+ */
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { describe, expect, it } from 'vitest';
13
+
14
+ // doctor.js is CommonJS; import the whole module namespace.
15
+ const doctor = require('../doctor.js');
16
+
17
+ function repoWithLedger(rows) {
18
+ const root = mkdtempSync(join(tmpdir(), 'xp-gate-ledger-'));
19
+ mkdirSync(join(root, '.xp-gate'), { recursive: true });
20
+ const nl = String.fromCharCode(10);
21
+ writeFileSync(join(root, '.xp-gate', 'audit.jsonl'), rows.map((r) => JSON.stringify(r)).join(nl) + nl);
22
+ return root;
23
+ }
24
+
25
+ describe('doctor audit-ledger health (P0 telemetry)', () => {
26
+ it('@covers AUDIT-001-11 a fresh clean ledger passes', () => {
27
+ const future = '2030-01-01T00:00:00.000Z';
28
+ const root = repoWithLedger([
29
+ { timestamp: future, gate_id: 'gate-1', duration_ms: 100, commit_hash: 'abc', passed: true, issues_found: 0 },
30
+ ]);
31
+ const { checks, issues } = doctor.diagnoseAuditLedger({ repoRoot: root });
32
+ expect(issues).toBe(0);
33
+ expect(checks[0].status).toBe('PASS');
34
+ rmSync(root, { recursive: true, force: true });
35
+ });
36
+
37
+ it('@covers AUDIT-001-11 new epoch/blank-commit rows raise a WARN, grandfather old ones', () => {
38
+ const future = '2030-01-01T00:00:00.000Z';
39
+ const old = '2020-01-01T00:00:00.000Z';
40
+ const root = repoWithLedger([
41
+ // grandfathered — both anomalies, but before the fix date
42
+ { timestamp: old, gate_id: 'gate-1', duration_ms: 1750000000000, commit_hash: '', passed: true, issues_found: 0 },
43
+ // NEW anomalies — must be counted
44
+ { timestamp: future, gate_id: 'gate-2', duration_ms: 1750000000000, commit_hash: 'abc', passed: true, issues_found: 0 },
45
+ { timestamp: future, gate_id: 'gate-3', duration_ms: 100, commit_hash: '', passed: true, issues_found: 0 },
46
+ ]);
47
+ const { checks, issues } = doctor.diagnoseAuditLedger({ repoRoot: root });
48
+ expect(issues).toBe(0); // WARN, not a blocking issue count
49
+ expect(checks[0].status).toBe('WARN');
50
+ expect(checks[0].detail).toContain('2 anomalies');
51
+ rmSync(root, { recursive: true, force: true });
52
+ });
53
+
54
+ it('@covers AUDIT-001-11 no ledger is a SKIP, not a failure', () => {
55
+ const root = mkdtempSync(join(tmpdir(), 'xp-gate-ledger-empty-'));
56
+ const { checks, issues } = doctor.diagnoseAuditLedger({ repoRoot: root });
57
+ expect(issues).toBe(0);
58
+ expect(checks[0].status).toBe('SKIP');
59
+ rmSync(root, { recursive: true, force: true });
60
+ });
61
+ });