@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.
- package/adapters/gate-3.sh +483 -28
- package/adapters/gate-4.sh +20 -0
- package/adapters/gate-7.sh +4 -0
- package/bin/xp-gate.js +50 -4
- package/gate-4.sh +8 -0
- package/hooks/gate-4.sh +8 -0
- package/hooks/post-commit +82 -0
- package/hooks/pre-commit +19 -0
- package/hooks/pre-push +21 -2
- package/lib/__tests__/check-changed-brief.test.js +63 -0
- package/lib/__tests__/doctor-audit-ledger.test.js +61 -0
- package/lib/__tests__/gate-audit.test.ts +144 -0
- package/lib/__tests__/shared-utils.test.js +22 -0
- package/lib/check.js +114 -2
- package/lib/doctor.js +59 -1
- package/lib/gate-audit.ts +215 -7
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-utils.js +4 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +47 -0
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/copy-client.cjs +6 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +47 -0
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/src/contract.test.ts +25 -1
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +47 -0
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +47 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/scripts/sync-package-content.js +1 -1
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/sprint-flow/references/phase-3-build.md +47 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
});
|