@boyingliu01/xp-gate 0.32.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/adapters/gate-3.sh +483 -28
  2. package/adapters/gate-4.sh +20 -0
  3. package/adapters/gate-7.sh +4 -0
  4. package/bin/xp-gate.js +50 -4
  5. package/gate-4.sh +8 -0
  6. package/hooks/gate-4.sh +8 -0
  7. package/hooks/post-commit +82 -0
  8. package/hooks/pre-commit +19 -0
  9. package/hooks/pre-push +21 -2
  10. package/lib/__tests__/check-changed-brief.test.js +63 -0
  11. package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
  12. package/lib/__tests__/gate-audit.test.ts +144 -0
  13. package/lib/__tests__/shared-utils.test.js +22 -0
  14. package/lib/check.js +114 -2
  15. package/lib/doctor.js +59 -1
  16. package/lib/gate-audit.ts +215 -7
  17. package/lib/mattpocock-license-verdict.json +1 -1
  18. package/lib/shared-utils.js +4 -1
  19. package/mock-policy/AGENTS.md +2 -2
  20. package/mutation/AGENTS.md +2 -2
  21. package/package.json +1 -1
  22. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  23. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  25. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +47 -0
  26. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  27. package/plugins/dsh/package.json +1 -1
  28. package/plugins/dsh/scripts/copy-client.cjs +6 -1
  29. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +47 -0
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/plugins/dsh/src/contract.test.ts +25 -1
  34. package/plugins/opencode/package.json +1 -1
  35. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  36. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  37. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +47 -0
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +47 -0
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/scripts/sync-package-content.js +1 -1
  46. package/skills/delphi-review/AGENTS.md +2 -2
  47. package/skills/sprint-flow/AGENTS.md +2 -2
  48. package/skills/sprint-flow/references/phase-3-build.md +47 -0
  49. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -295,9 +295,9 @@ const COMMANDS = {
295
295
  usage: 'xp-gate baseline <create|show|reset|diff>'
296
296
  },
297
297
  'check': {
298
- description: 'Run quality gates (--list, --all, --gates <ids>, or <gate-id>)',
298
+ description: 'Run quality gates (--list, --all, --gates <ids>, --changed --brief, or <gate-id>)',
299
299
  run: subargs => check(subargs).then(code => process.exit(code)),
300
- usage: 'xp-gate check <file_or_directory|gate_number> [--gates gates] [--all] [--list]'
300
+ usage: 'xp-gate check <file_or_directory|gate_number> [--gates gates] [--all] [--changed --brief] [--list]'
301
301
  },
302
302
  'principles': {
303
303
  description: 'Run Clean Code + SOLID principles checker (Gate 4 standalone)',
@@ -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-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-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,63 @@
1
+ /**
2
+ * @test check-changed-brief
3
+ * @intent Prove `xp-gate check --changed --brief` scopes to the current diff, prints one honest verdict per gate, and never reports a gate as PASS when it did not run
4
+ * @covers AC-DSH-019
5
+ */
6
+ const { fileExtension, printBriefSummary } = require('../check.js');
7
+
8
+ describe('check --changed --brief helpers', () => {
9
+ describe('fileExtension', () => {
10
+ it('returns the lowercased extension without the dot', () => {
11
+ expect(fileExtension('src/a/B.TS')).toBe('ts');
12
+ expect(fileExtension('x/y/z.mjs')).toBe('mjs');
13
+ });
14
+
15
+ it('returns empty string for a path without an extension', () => {
16
+ expect(fileExtension('Makefile')).toBe('');
17
+ expect(fileExtension('docs/README')).toBe('');
18
+ });
19
+ });
20
+
21
+ describe('printBriefSummary', () => {
22
+ function capture(fn) {
23
+ const lines = [];
24
+ const spy = vi.spyOn(console, 'log').mockImplementation((...a) => lines.push(a.join(' ')));
25
+ const ret = fn();
26
+ spy.mockRestore();
27
+ return { out: lines.join('\n'), ret };
28
+ }
29
+
30
+ it('reports one PASS line per passing gate and returns 0 failures', () => {
31
+ const { out, ret } = capture(() =>
32
+ printBriefSummary(['a.ts'], ['a.ts'], [{ id: '3', code: 0 }, { id: '4', code: 0 }], 12),
33
+ );
34
+ expect(out).toMatch(/Gate 3 Complexity\s+PASS/);
35
+ expect(out).toMatch(/Gate 4 Principles\s+PASS/);
36
+ expect(out).toMatch(/✅ clear/);
37
+ expect(ret).toBe(0);
38
+ });
39
+
40
+ it('reports BLOCK and counts failures when a gate fails', () => {
41
+ const { out, ret } = capture(() =>
42
+ printBriefSummary(['a.ts'], ['a.ts'], [{ id: '3', code: 0 }, { id: '4', code: 1 }], 12),
43
+ );
44
+ expect(out).toMatch(/Gate 4 Principles\s+BLOCK/);
45
+ expect(out).toMatch(/❌ 1 blocking/);
46
+ expect(ret).toBe(1);
47
+ });
48
+
49
+ it('never prints PASS for a gate that did not run (NOT_RUN -> SKIP)', () => {
50
+ const { out, ret } = capture(() =>
51
+ printBriefSummary(['a.ts'], ['a.ts'], [{ id: '3', code: 'NOT_RUN' }], 5),
52
+ );
53
+ expect(out).toMatch(/Gate 3 Complexity\s+SKIP/);
54
+ expect(out).not.toMatch(/Gate 3 Complexity\s+PASS/);
55
+ expect(ret).toBe(0);
56
+ });
57
+
58
+ it('states docs/config-only diffs apply no blocking source gate', () => {
59
+ const { out } = capture(() => printBriefSummary(['README.md'], [], [], 3));
60
+ expect(out).toMatch(/No source files changed/);
61
+ });
62
+ });
63
+ });
@@ -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
+ });
@@ -13,6 +13,12 @@ import {
13
13
  rotateIfNeeded,
14
14
  parseCliOptions,
15
15
  getCommitHash,
16
+ resolveCommitHash,
17
+ stageAuditEntry,
18
+ flushPendingAudit,
19
+ sanitizeDurationMs,
20
+ computeAnomalies,
21
+ AUDIT_ANOMALY_SINCE,
16
22
  type GateAuditEntry,
17
23
  } from '../gate-audit';
18
24
 
@@ -293,4 +299,142 @@ describe('gate-audit', () => {
293
299
  expect(existsSync(`${logPath}.4`)).toBe(false);
294
300
  });
295
301
  });
302
+ describe('pre-commit staging + post-commit flush (commit attribution)', () => {
303
+ const NL = String.fromCharCode(10);
304
+ const repo = () => {
305
+ const r = join(TEST_DIR, 'attr');
306
+ mkdirSync(r, { recursive: true });
307
+ return r;
308
+ };
309
+
310
+ it('@covers AUDIT-001-07 resolveCommitHash prefers an explicit --commit-hash', () => {
311
+ expect(resolveCommitHash({ 'commit-hash': 'deadbeef' }, () => 'PARENT')).toBe('deadbeef');
312
+ });
313
+
314
+ it('@covers AUDIT-001-07 resolveCommitHash falls back to HEAD when no explicit hash', () => {
315
+ expect(resolveCommitHash({}, () => 'HEADHASH')).toBe('HEADHASH');
316
+ });
317
+
318
+ it('@covers AUDIT-001-08 flushPendingAudit stamps staged entries with the real commit', () => {
319
+ const r = repo();
320
+ stageAuditEntry(makeEntry({ repo_path: r, gate_id: 'gate-1', commit_hash: '' }), r);
321
+ stageAuditEntry(makeEntry({ repo_path: r, gate_id: 'gate-2', commit_hash: '' }), r);
322
+ const logPath = join(r, '.xp-gate', 'audit.jsonl');
323
+ expect(existsSync(logPath)).toBe(false);
324
+
325
+ const n = flushPendingAudit(r, 'REAL_COMMIT_SHA');
326
+ expect(n).toBe(2);
327
+
328
+ const entries = readFileSync(logPath, 'utf8').trim().split(NL).map((l) => JSON.parse(l));
329
+ expect(entries.map((e) => e.gate_id)).toEqual(['gate-1', 'gate-2']);
330
+ expect(entries.every((e) => e.commit_hash === 'REAL_COMMIT_SHA')).toBe(true);
331
+ expect(existsSync(join(r, '.xp-gate', 'audit.pending.jsonl'))).toBe(false);
332
+ });
333
+
334
+ it('@covers AUDIT-001-08 flush is idempotent and a no-op with nothing staged', () => {
335
+ const r = repo();
336
+ expect(flushPendingAudit(r, 'X')).toBe(0);
337
+ expect(existsSync(join(r, '.xp-gate', 'audit.jsonl'))).toBe(false);
338
+ });
339
+
340
+ it('@covers AUDIT-001-08 a blank commit hash never invents one', () => {
341
+ const r = repo();
342
+ stageAuditEntry(makeEntry({ repo_path: r, gate_id: 'gate-9', commit_hash: '' }), r);
343
+ expect(flushPendingAudit(r, '')).toBe(1);
344
+ const e = JSON.parse(readFileSync(join(r, '.xp-gate', 'audit.jsonl'), 'utf8').trim());
345
+ expect(e.commit_hash).toBe('');
346
+ });
347
+
348
+ it('@covers AUDIT-001-08 a torn pending line is dropped, not fatal', () => {
349
+ const r = repo();
350
+ mkdirSync(join(r, '.xp-gate'), { recursive: true });
351
+ const pp = join(r, '.xp-gate', 'audit.pending.jsonl');
352
+ const good = JSON.stringify(makeEntry({ repo_path: r, gate_id: 'gate-3', commit_hash: '' }));
353
+ writeFileSync(pp, good + NL + '{ broken' + NL, 'utf8');
354
+ expect(flushPendingAudit(r, 'SHA')).toBe(1);
355
+ const entries = readFileSync(join(r, '.xp-gate', 'audit.jsonl'), 'utf8').trim().split(NL);
356
+ expect(entries.length).toBe(1);
357
+ });
358
+ });
359
+
360
+ describe('sanitizeDurationMs (writer-side epoch guard, REQ-B backstop)', () => {
361
+ it('@covers AUDIT-001-09 keeps a plausible duration untouched', () => {
362
+ expect(sanitizeDurationMs(1234)).toEqual({ duration_ms: 1234 });
363
+ expect(sanitizeDurationMs(0)).toEqual({ duration_ms: 0 });
364
+ });
365
+
366
+ it('@covers AUDIT-001-09 null stays null with no reason', () => {
367
+ expect(sanitizeDurationMs(null)).toEqual({ duration_ms: null });
368
+ });
369
+
370
+ it('@covers AUDIT-001-09 an epoch-sized duration is rejected, not stored as a number', () => {
371
+ // end - 0 == Date.now() ~= 1.7e12 — the classic poisoned-start bug.
372
+ const r = sanitizeDurationMs(1750000000000);
373
+ expect(r.duration_ms).toBeNull();
374
+ expect(r.duration_reason).toBe('poisoned_start_ms');
375
+ });
376
+
377
+ it('@covers AUDIT-001-09 negative or NaN durations are rejected', () => {
378
+ expect(sanitizeDurationMs(-5).duration_reason).toBe('poisoned_start_ms');
379
+ expect(sanitizeDurationMs(Number.NaN).duration_reason).toBe('poisoned_start_ms');
380
+ });
381
+ });
382
+
383
+ describe('computeAnomalies (CI ledger gate, P0-2)', () => {
384
+ const NL = String.fromCharCode(10);
385
+ const repo = () => {
386
+ const r = join(TEST_DIR, 'anom');
387
+ mkdirSync(r, { recursive: true });
388
+ return r;
389
+ };
390
+ const write = (r: string, rows: Partial<GateAuditEntry>[]) => {
391
+ mkdirSync(join(r, '.xp-gate'), { recursive: true });
392
+ const lines = rows.map((x) => JSON.stringify(makeEntry({ repo_path: r, ...x }))).join(NL);
393
+ writeFileSync(join(r, '.xp-gate', 'audit.jsonl'), lines + NL, 'utf8');
394
+ };
395
+
396
+ it('@covers AUDIT-001-10 a clean ledger has zero anomalies', () => {
397
+ const r = repo();
398
+ write(r, [{ gate_id: 'gate-1', commit_hash: 'abc', duration_ms: 100 }]);
399
+ const a = computeAnomalies(r);
400
+ expect(a.total).toBe(0);
401
+ expect(a.scanned).toBe(1);
402
+ });
403
+
404
+ it('@covers AUDIT-001-10 epoch duration + blank commit are both counted', () => {
405
+ const r = repo();
406
+ write(r, [
407
+ { gate_id: 'gate-1', commit_hash: 'abc', duration_ms: 1750000000000 },
408
+ { gate_id: 'gate-2', commit_hash: '', duration_ms: 100 },
409
+ { gate_id: 'gate-3', commit_hash: 'unknown', duration_ms: 100 },
410
+ { gate_id: 'gate-4', commit_hash: 'def', duration_ms: 50 },
411
+ ]);
412
+ const a = computeAnomalies(r);
413
+ expect(a.implausible_duration).toBe(1);
414
+ expect(a.missing_commit).toBe(2);
415
+ expect(a.total).toBe(3);
416
+ });
417
+
418
+ it('@covers AUDIT-001-10 null+reason is honest, not an anomaly', () => {
419
+ const r = repo();
420
+ write(r, [{ gate_id: 'gate-1', commit_hash: 'abc', duration_ms: null, duration_reason: 'poisoned_start_ms' }]);
421
+ const a = computeAnomalies(r);
422
+ expect(a.null_with_reason).toBe(1);
423
+ expect(a.total).toBe(0);
424
+ });
425
+
426
+ it('@covers AUDIT-001-10 since-scope ignores grandfathered anomalies', () => {
427
+ const r = repo();
428
+ write(r, [
429
+ { gate_id: 'gate-1', commit_hash: '', duration_ms: 1750000000000, timestamp: '2020-01-01T00:00:00.000Z' },
430
+ { gate_id: 'gate-2', commit_hash: 'abc', duration_ms: 100, timestamp: '2030-01-01T00:00:00.000Z' },
431
+ ]);
432
+ const all = computeAnomalies(r);
433
+ expect(all.total).toBe(2); // old row: 1 implausible + 1 missing commit
434
+ const scoped = computeAnomalies(r, AUDIT_ANOMALY_SINCE);
435
+ expect(scoped.total).toBe(0);
436
+ expect(scoped.scanned).toBe(1);
437
+ });
438
+ });
439
+
296
440
  });
@@ -87,4 +87,26 @@ describe('shared-utils', () => {
87
87
  expect(fs.existsSync(path.join(destDir, 'lib', 'now-ms.sh'))).toBe(true);
88
88
  });
89
89
  });
90
+ describe('copyHooks ships post-commit (audit flush + bypass detection)', () => {
91
+ it('@covers gate-script deployment: copyHooks installs post-commit, not just pre-commit/pre-push/post-merge', () => {
92
+ // Regression: post-commit was absent from both the ship list and copyHooks,
93
+ // so the audit-attribution flush and --no-verify bypass detection that live
94
+ // in post-commit never executed for any installed user.
95
+ const pkgRoot = path.join(tmpRoot, 'node_modules', '@boyingliu01', 'xp-gate');
96
+ const hooksSrc = path.join(pkgRoot, 'hooks');
97
+ fs.mkdirSync(hooksSrc, { recursive: true });
98
+ for (const h of ['pre-commit', 'pre-push', 'post-commit', 'post-merge']) {
99
+ fs.writeFileSync(path.join(hooksSrc, h), `# ${h}`);
100
+ }
101
+ const destDir = path.join(tmpRoot, 'dest-hooks');
102
+ fs.mkdirSync(destDir, { recursive: true });
103
+
104
+ const { copyHooks } = require('../shared-utils');
105
+ copyHooks(pkgRoot, destDir);
106
+
107
+ expect(fs.existsSync(path.join(destDir, 'post-commit'))).toBe(true);
108
+ expect(fs.existsSync(path.join(destDir, 'pre-commit'))).toBe(true);
109
+ });
110
+ });
111
+
90
112
  });