@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.
- package/adapters/gate-3.sh +483 -28
- package/adapters/gate-4.sh +20 -0
- package/adapters/gate-7.sh +4 -0
- package/adapters/powershell.sh +33 -7
- package/bin/xp-gate.js +48 -2
- package/gate-3.sh +37 -2
- package/gate-4.sh +8 -0
- package/hooks/gate-3.sh +37 -2
- 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__/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/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 +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/README.md +51 -2
- package/plugins/dsh/client/client.js +760 -0
- package/plugins/dsh/lib/client.js +760 -0
- package/plugins/dsh/lib/delphi-settings.d.ts +52 -0
- package/plugins/dsh/lib/delphi-settings.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-settings.js +76 -0
- package/plugins/dsh/lib/hooks.d.ts +14 -1
- package/plugins/dsh/lib/hooks.d.ts.map +1 -1
- package/plugins/dsh/lib/hooks.js +44 -5
- package/plugins/dsh/lib/index.d.ts +63 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +126 -21
- package/plugins/dsh/package.json +13 -3
- package/plugins/dsh/scripts/copy-client.cjs +37 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/client.test.ts +231 -0
- package/plugins/dsh/src/contract.test.ts +25 -1
- package/plugins/dsh/src/delphi-settings.test.ts +101 -0
- package/plugins/dsh/src/delphi-settings.ts +111 -0
- package/plugins/dsh/src/hooks.test.ts +48 -0
- package/plugins/dsh/src/hooks.ts +45 -5
- package/plugins/dsh/src/index.test.ts +3 -2
- package/plugins/dsh/src/index.ts +147 -25
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/sync-package-content.js +1 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/references/phase-3-build.md +21 -0
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/adapters/powershell.sh
CHANGED
|
@@ -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
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,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
|
+
});
|