@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/lib/check.js
CHANGED
|
@@ -13,13 +13,15 @@ function resolveGateToken(token) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function parseCheckArgs(args) {
|
|
16
|
-
const flags = { all: false, list: false, gateIds: null };
|
|
16
|
+
const flags = { all: false, list: false, gateIds: null, changed: false, brief: false };
|
|
17
17
|
const posArgs = [];
|
|
18
18
|
let i = 0;
|
|
19
19
|
while (i < args.length) {
|
|
20
20
|
const arg = args[i];
|
|
21
21
|
if (arg === '--all') { flags.all = true; i++; }
|
|
22
22
|
else if (arg === '--list') { flags.list = true; i++; }
|
|
23
|
+
else if (arg === '--changed') { flags.changed = true; i++; }
|
|
24
|
+
else if (arg === '--brief') { flags.brief = true; i++; }
|
|
23
25
|
else if (arg === '--gates' && i + 1 < args.length) {
|
|
24
26
|
flags.gateIds = args[i + 1].split(',').map(s => s.trim()).filter(Boolean);
|
|
25
27
|
i += 2;
|
|
@@ -45,6 +47,7 @@ async function check(args) {
|
|
|
45
47
|
const opts = parseCheckArgs(args);
|
|
46
48
|
|
|
47
49
|
if (opts.list) return listGates();
|
|
50
|
+
if (opts.changed) return runChangedBrief();
|
|
48
51
|
if (opts.gateNumber && !opts.all && !opts.gateIds) return runGate(opts.gateNumber, opts.pathArg);
|
|
49
52
|
if (opts.all) return checkAllGates(opts.pathArg);
|
|
50
53
|
if (opts.gateIds && opts.gateIds.length > 0) return runGatesByIds(opts.gateIds, opts.pathArg);
|
|
@@ -55,6 +58,7 @@ async function check(args) {
|
|
|
55
58
|
console.error('Options:');
|
|
56
59
|
console.error(' --gates <ids> Run specific gates (comma-separated IDs or aliases, e.g. "version,4,secrets")');
|
|
57
60
|
console.error(' --all Run all invokable gates');
|
|
61
|
+
console.error(' --changed --brief Fast: run only the gates the current diff can trip, one-screen summary');
|
|
58
62
|
console.error(' --list List all available gates');
|
|
59
63
|
console.error('');
|
|
60
64
|
console.error('Available Gates:');
|
|
@@ -68,6 +72,7 @@ async function check(args) {
|
|
|
68
72
|
console.error(' xp-gate check secrets # Run Gate 8 via alias');
|
|
69
73
|
console.error(' xp-gate check . --gates version,4,secrets # Run gates 0, 4, and 8');
|
|
70
74
|
console.error(' xp-gate check . --all # Run all invokable gates');
|
|
75
|
+
console.error(' xp-gate check . --changed --brief # Fast: only what this diff trips');
|
|
71
76
|
console.error(' xp-gate check --list # List available gates');
|
|
72
77
|
return 1;
|
|
73
78
|
}
|
|
@@ -212,6 +217,113 @@ async function runGatesByIds(gateIds, target) {
|
|
|
212
217
|
return failures === 0 ? 0 : 1;
|
|
213
218
|
}
|
|
214
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Fast "what will this commit trip?" view (P1-4, diagnosis 2026-10-30).
|
|
222
|
+
*
|
|
223
|
+
* `check . --all` runs the whole tree (minutes). During BUILD the developer only
|
|
224
|
+
* needs to know which blocking rules the CURRENT diff touches, in one screen and
|
|
225
|
+
* in seconds. This maps the diff to the gates that read changed files and runs
|
|
226
|
+
* only those, so the common case (docs / a couple of sources) returns fast.
|
|
227
|
+
*
|
|
228
|
+
* Honest by construction: prints only gate->verdict, never implying a gate ran
|
|
229
|
+
* when it was skipped. Gates not modelled here are listed as "commit-time".
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/** Extension of a path, lowercased, without the dot ('' when none). */
|
|
233
|
+
function fileExtension(file) {
|
|
234
|
+
const m = /\.([a-z0-9]+)$/i.exec(file);
|
|
235
|
+
return m ? m[1].toLowerCase() : '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Wall-clock budget for one `git diff` used by the --changed resolver. */
|
|
239
|
+
const GIT_DIFF_TIMEOUT_MS = 10000;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run one git diff and split its stdout into file names. Returns null ONLY for a
|
|
243
|
+
* failed diff (not a git repo); any git error is caught and mapped to null so the
|
|
244
|
+
* caller can report "not a repository" instead of crashing on a null status.
|
|
245
|
+
*/
|
|
246
|
+
function gitChangedNames(spawnSync, args) {
|
|
247
|
+
try {
|
|
248
|
+
const res = spawnSync('git', args, { encoding: 'utf-8', timeout: GIT_DIFF_TIMEOUT_MS });
|
|
249
|
+
if (res.status !== 0 || typeof res.stdout !== 'string') return null;
|
|
250
|
+
return res.stdout.split('\n').map((f) => f.trim()).filter(Boolean);
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Diff scope mirrors pre-commit: staged first, then anything vs HEAD. */
|
|
257
|
+
function readChangedFiles() {
|
|
258
|
+
const { spawnSync } = require('child_process');
|
|
259
|
+
const staged = gitChangedNames(spawnSync, ['diff', '--cached', '--name-only', '--diff-filter=ACM']);
|
|
260
|
+
if (staged === null) return null;
|
|
261
|
+
if (staged.length > 0) return staged;
|
|
262
|
+
const vsHead = gitChangedNames(spawnSync, ['diff', 'HEAD', '--name-only', '--diff-filter=ACM']);
|
|
263
|
+
return vsHead === null ? staged : vsHead;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Run the gates a diff can trip that are both diff-scoped and fast. Gate 2
|
|
268
|
+
* (jscpd) is excluded on purpose: it scans the whole tree and duplicate
|
|
269
|
+
* detection is a codebase-wide question, not a per-diff one. Gate 4 is handed the
|
|
270
|
+
* actual files — calling it with '.' examined 0 files and printed a vacuous
|
|
271
|
+
* "No violations found", the exact anti-pattern this project forbids.
|
|
272
|
+
*/
|
|
273
|
+
async function runBriefGates(sourceFiles) {
|
|
274
|
+
const results = [];
|
|
275
|
+
if (sourceFiles.length === 0) return results;
|
|
276
|
+
results.push({ id: '3', code: await runGate('3', '.', {}) });
|
|
277
|
+
const { principles } = require('./principles.js');
|
|
278
|
+
results.push({ id: '4', code: await principles(sourceFiles) });
|
|
279
|
+
return results;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const BRIEF_GATE_NAMES = { '2': 'Duplicates', '3': 'Complexity', '4': 'Principles' };
|
|
283
|
+
const COMMIT_TIME_GATES = ['1 lint', '5 tests', '6 Boy Scout', '7 IaC', '8 secrets', '9 sast', '10 build', '11 sprint'];
|
|
284
|
+
|
|
285
|
+
/** Print the one-screen summary; returns the number of blocking gates. */
|
|
286
|
+
function printBriefSummary(changed, sourceFiles, results, ms) {
|
|
287
|
+
console.log('');
|
|
288
|
+
console.log(`━━━ xp-gate check --changed (${changed.length} file(s), ${sourceFiles.length} source) ━━━`);
|
|
289
|
+
if (sourceFiles.length === 0) {
|
|
290
|
+
console.log(' No source files changed — no blocking source gate applies (docs/config only).');
|
|
291
|
+
}
|
|
292
|
+
let failures = 0;
|
|
293
|
+
for (const r of results) {
|
|
294
|
+
let verdict;
|
|
295
|
+
if (r.code === 0) verdict = 'PASS';
|
|
296
|
+
else if (r.code === 'NOT_RUN') verdict = 'SKIP';
|
|
297
|
+
else { verdict = 'BLOCK'; failures += 1; }
|
|
298
|
+
console.log(` Gate ${r.id} ${(BRIEF_GATE_NAMES[r.id] || r.id).padEnd(12)} ${verdict}`);
|
|
299
|
+
}
|
|
300
|
+
const rest = sourceFiles.length > 0 ? COMMIT_TIME_GATES.filter((g) => !/^(1 |5 |6 )/.test(g)) : COMMIT_TIME_GATES;
|
|
301
|
+
console.log(` commit-time (not run here): ${rest.join(', ')}`);
|
|
302
|
+
console.log(` ${failures === 0 ? '✅ clear' : `❌ ${failures} blocking`} — ${ms} ms`);
|
|
303
|
+
return failures;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function runChangedBrief() {
|
|
307
|
+
const { SOURCE_FILE_EXTENSIONS } = require('./gate-runner.js');
|
|
308
|
+
const t0 = Date.now();
|
|
309
|
+
|
|
310
|
+
const changed = readChangedFiles();
|
|
311
|
+
if (changed === null) {
|
|
312
|
+
console.error('[xp-gate check --changed] not a git repository');
|
|
313
|
+
return 1;
|
|
314
|
+
}
|
|
315
|
+
if (changed.length === 0) {
|
|
316
|
+
console.log('[xp-gate check --changed] no staged/tracked changes — nothing to gate (0 files).');
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const srcExt = new Set(SOURCE_FILE_EXTENSIONS);
|
|
321
|
+
const sourceFiles = changed.filter((f) => srcExt.has(fileExtension(f)));
|
|
322
|
+
const results = await runBriefGates(sourceFiles);
|
|
323
|
+
const failures = printBriefSummary(changed, sourceFiles, results, Date.now() - t0);
|
|
324
|
+
return failures === 0 ? 0 : 1;
|
|
325
|
+
}
|
|
326
|
+
|
|
215
327
|
function listGates() {
|
|
216
328
|
const { getAllGates } = require('./gate-runner.js');
|
|
217
329
|
const allGates = getAllGates();
|
|
@@ -244,4 +356,4 @@ function listGates() {
|
|
|
244
356
|
return 0;
|
|
245
357
|
}
|
|
246
358
|
|
|
247
|
-
module.exports = { check, checkAllGates, formatGateTally };
|
|
359
|
+
module.exports = { check, checkAllGates, formatGateTally, runChangedBrief, fileExtension, printBriefSummary };
|
package/lib/doctor.js
CHANGED
|
@@ -676,6 +676,59 @@ function readPair(repoFile, installedFile) {
|
|
|
676
676
|
* @param {{repoRoot?: string, repoHooks?: string, installedAdapters?: string}} [ctx]
|
|
677
677
|
* @returns {{checks: Array<{name: string, status: string, detail: string}>, issues: number}}
|
|
678
678
|
*/
|
|
679
|
+
/**
|
|
680
|
+
* Audit ledger health (P0): surface gate-telemetry anomalies so `doctor` warns
|
|
681
|
+
* before they silently poison per-commit analysis. Mirrors gate-audit.ts's
|
|
682
|
+
* computeAnomalies (kept in sync deliberately — doctor is CommonJS and cannot
|
|
683
|
+
* import the .ts module). Grandfathers everything before AUDIT_LEDGER_SINCE;
|
|
684
|
+
* only NEW implausible durations or unattributed commits count as issues.
|
|
685
|
+
*/
|
|
686
|
+
const AUDIT_LEDGER_SINCE = '2026-10-30T00:00:00.000Z';
|
|
687
|
+
const AUDIT_MAX_PLAUSIBLE_MS = 6 * 60 * 60 * 1000;
|
|
688
|
+
|
|
689
|
+
function diagnoseAuditLedger(ctx = {}) {
|
|
690
|
+
const checks = [];
|
|
691
|
+
let issues = 0;
|
|
692
|
+
const repoRoot = ctx.repoRoot || process.cwd();
|
|
693
|
+
const logPath = path.join(repoRoot, '.xp-gate', 'audit.jsonl');
|
|
694
|
+
if (!fs.existsSync(logPath)) {
|
|
695
|
+
checks.push({ name: 'Audit ledger', status: 'SKIP', detail: 'no .xp-gate/audit.jsonl' });
|
|
696
|
+
return { checks, issues };
|
|
697
|
+
}
|
|
698
|
+
let entries;
|
|
699
|
+
try {
|
|
700
|
+
entries = fs.readFileSync(logPath, 'utf8').split(/\r?\n/).filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
701
|
+
} catch (err) {
|
|
702
|
+
checks.push({ name: 'Audit ledger', status: 'WARN', detail: `unreadable: ${err.message}` });
|
|
703
|
+
return { checks, issues };
|
|
704
|
+
}
|
|
705
|
+
let implausible = 0;
|
|
706
|
+
let missingCommit = 0;
|
|
707
|
+
let considered = 0;
|
|
708
|
+
for (const e of entries) {
|
|
709
|
+
if (typeof e.timestamp === 'string' && e.timestamp < AUDIT_LEDGER_SINCE) continue;
|
|
710
|
+
considered += 1;
|
|
711
|
+
if (typeof e.duration_ms === 'number'
|
|
712
|
+
&& (!Number.isFinite(e.duration_ms) || e.duration_ms < 0 || e.duration_ms > AUDIT_MAX_PLAUSIBLE_MS)) {
|
|
713
|
+
implausible += 1;
|
|
714
|
+
}
|
|
715
|
+
if (!e.commit_hash || e.commit_hash === 'unknown') missingCommit += 1;
|
|
716
|
+
}
|
|
717
|
+
const total = implausible + missingCommit;
|
|
718
|
+
if (total > 0) {
|
|
719
|
+
checks.push({
|
|
720
|
+
name: 'Audit ledger',
|
|
721
|
+
status: 'WARN',
|
|
722
|
+
detail: `${total} anomal${total === 1 ? 'y' : 'ies'} since ${AUDIT_LEDGER_SINCE} (${implausible} implausible duration, ${missingCommit} unattributed) — run: xp-gate audit --check`,
|
|
723
|
+
});
|
|
724
|
+
// WARN, not FAIL: telemetry integrity is not a blocking gate, but it must
|
|
725
|
+
// be visible. Kept at 0 issues so it never inflates the FAIL count.
|
|
726
|
+
} else {
|
|
727
|
+
checks.push({ name: 'Audit ledger', status: 'PASS', detail: `${considered} entr${considered === 1 ? 'y' : 'ies'} clean since ${AUDIT_LEDGER_SINCE}` });
|
|
728
|
+
}
|
|
729
|
+
return { checks, issues };
|
|
730
|
+
}
|
|
731
|
+
|
|
679
732
|
function diagnoseModuleDrift(ctx = {}) {
|
|
680
733
|
const checks = [];
|
|
681
734
|
let issues = 0;
|
|
@@ -1547,7 +1600,11 @@ async function diagnoseAsync(refreshEnv = false) {
|
|
|
1547
1600
|
// hook files and live in a different directory (#495).
|
|
1548
1601
|
const moduleDrift = diagnoseModuleDrift();
|
|
1549
1602
|
checks.push(...moduleDrift.checks);
|
|
1550
|
-
|
|
1603
|
+
// Audit-ledger health (P0): telemetry anomalies are not hook drift, but
|
|
1604
|
+
// they ride the same repo-scoped group and must be surfaced by doctor.
|
|
1605
|
+
const ledger = diagnoseAuditLedger();
|
|
1606
|
+
checks.push(...ledger.checks);
|
|
1607
|
+
return drift.issues + moduleDrift.issues + ledger.issues;
|
|
1551
1608
|
});
|
|
1552
1609
|
|
|
1553
1610
|
// Wait for ALL parallel groups
|
|
@@ -3225,6 +3282,7 @@ module.exports = {
|
|
|
3225
3282
|
diagnoseHookDrift,
|
|
3226
3283
|
syncGlobalHooksFromRepo,
|
|
3227
3284
|
diagnoseModuleDrift,
|
|
3285
|
+
diagnoseAuditLedger,
|
|
3228
3286
|
syncModulesFromRepo,
|
|
3229
3287
|
resolveExecutedModuleDirs,
|
|
3230
3288
|
executedModulePath,
|
package/lib/gate-audit.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
renameSync,
|
|
15
15
|
statSync,
|
|
16
16
|
chmodSync,
|
|
17
|
+
rmSync,
|
|
17
18
|
} from 'fs';
|
|
18
19
|
import { join } from 'path';
|
|
19
20
|
|
|
@@ -23,6 +24,7 @@ const AUDIT_DIR = '.xp-gate';
|
|
|
23
24
|
const AUDIT_FILE = 'audit.jsonl';
|
|
24
25
|
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
25
26
|
const MAX_ARCHIVES = 3;
|
|
27
|
+
const MAX_PLAUSIBLE_DURATION_MS = 6 * 60 * 60 * 1000; // 6h — no single gate runs longer
|
|
26
28
|
|
|
27
29
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
28
30
|
|
|
@@ -85,6 +87,75 @@ export function appendAuditEntry(
|
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// ── Pre-commit staging ───────────────────────────────────────────────────────
|
|
91
|
+
// Pre-commit cannot know the commit it is about to create (HEAD is still the
|
|
92
|
+
// parent there), so per-gate entries are staged to a sidecar and stamped with
|
|
93
|
+
// the real commit by post-commit. Fallback safety: `flushPendingAudit` also
|
|
94
|
+
// runs at the START of the next pre-commit, so if post-commit never fired
|
|
95
|
+
// (hooks skipped, exception) the entries still land, attributed by `git log -1`
|
|
96
|
+
// at that later point — slightly late but never lost, never misattributed to
|
|
97
|
+
// the wrong commit when post-commit does run.
|
|
98
|
+
|
|
99
|
+
const PENDING_SUFFIX = 'audit.pending.jsonl';
|
|
100
|
+
|
|
101
|
+
function pendingPath(repoRoot: string): string {
|
|
102
|
+
return join(repoRoot, AUDIT_DIR, PENDING_SUFFIX);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stage one entry for the not-yet-created commit. Never throws. */
|
|
106
|
+
export function stageAuditEntry(entry: GateAuditEntry, repoRoot: string = process.cwd()): void {
|
|
107
|
+
try {
|
|
108
|
+
const dir = join(repoRoot, AUDIT_DIR);
|
|
109
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
110
|
+
appendFileSync(pendingPath(repoRoot), JSON.stringify(entry) + '\n', 'utf8');
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error('[xp-gate audit] Failed to stage entry:', (err as Error).message);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Move staged entries into audit.jsonl, stamped with the real commit hash.
|
|
118
|
+
* Idempotent and safe to call when nothing is staged. A blank/absent hash
|
|
119
|
+
* leaves `commit_hash` exactly as staged so a wrong commit is never invented.
|
|
120
|
+
*/
|
|
121
|
+
export function flushPendingAudit(
|
|
122
|
+
repoRoot: string = process.cwd(),
|
|
123
|
+
commitHash: string = '',
|
|
124
|
+
): number {
|
|
125
|
+
const p = pendingPath(repoRoot);
|
|
126
|
+
if (!existsSync(p)) return 0;
|
|
127
|
+
let flushed = 0;
|
|
128
|
+
try {
|
|
129
|
+
const raw = readFileSync(p, 'utf8');
|
|
130
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
let entry: GateAuditEntry;
|
|
133
|
+
try {
|
|
134
|
+
entry = JSON.parse(line) as GateAuditEntry;
|
|
135
|
+
} catch {
|
|
136
|
+
// A torn line (interrupted mid-write) is dropped rather than crashing
|
|
137
|
+
// the hook that is trying to flush a previous run's leftovers.
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (commitHash) entry.commit_hash = commitHash;
|
|
141
|
+
appendAuditEntry(entry, repoRoot);
|
|
142
|
+
flushed++;
|
|
143
|
+
}
|
|
144
|
+
rmSyncSafe(p);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error('[xp-gate audit] Failed to flush pending entries:', (err as Error).message);
|
|
147
|
+
}
|
|
148
|
+
return flushed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function rmSyncSafe(p: string): void {
|
|
152
|
+
try {
|
|
153
|
+
rmSync(p, { force: true });
|
|
154
|
+
} catch {
|
|
155
|
+
/* best effort */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
88
159
|
/**
|
|
89
160
|
* Read the last N entries from the audit log.
|
|
90
161
|
*/
|
|
@@ -152,7 +223,10 @@ function aggregateByGate(entries: GateAuditEntry[]): Map<string, { total: number
|
|
|
152
223
|
const buckets = new Map<string, { total: number; passed: number; ms: number; issues: number; anomaly_count: number }>();
|
|
153
224
|
|
|
154
225
|
for (const e of entries) {
|
|
155
|
-
|
|
226
|
+
// Tolerate malformed/legacy rows: a missing gate_id would otherwise create
|
|
227
|
+
// an `undefined` bucket key (and crash the sort comparing it to a string).
|
|
228
|
+
const gateId = typeof e.gate_id === 'string' && e.gate_id ? e.gate_id : 'unknown';
|
|
229
|
+
const b = buckets.get(gateId) ?? { total: 0, passed: 0, ms: 0, issues: 0, anomaly_count: 0 };
|
|
156
230
|
b.total += 1;
|
|
157
231
|
if (e.passed) b.passed += 1;
|
|
158
232
|
// Issue #370: exclude anomalous durations from ms aggregation. REQ-B: a
|
|
@@ -163,8 +237,10 @@ function aggregateByGate(entries: GateAuditEntry[]): Map<string, { total: number
|
|
|
163
237
|
} else if (typeof e.duration_ms === 'number') {
|
|
164
238
|
b.ms += e.duration_ms;
|
|
165
239
|
}
|
|
166
|
-
|
|
167
|
-
|
|
240
|
+
// A malformed/legacy row with a non-numeric issues_found must not turn
|
|
241
|
+
// the whole gate's average into NaN.
|
|
242
|
+
b.issues += Number.isFinite(e.issues_found) ? e.issues_found : 0;
|
|
243
|
+
buckets.set(gateId, b);
|
|
168
244
|
}
|
|
169
245
|
|
|
170
246
|
return buckets;
|
|
@@ -202,6 +278,70 @@ export function computeStats(
|
|
|
202
278
|
return results;
|
|
203
279
|
}
|
|
204
280
|
|
|
281
|
+
export interface AuditAnomalies {
|
|
282
|
+
/** Entries with an explicit duration_anomaly flag (Issue #370). */
|
|
283
|
+
flagged: number;
|
|
284
|
+
/** Entries with a null duration + reason (REQ-B unmeasurable). */
|
|
285
|
+
null_with_reason: number;
|
|
286
|
+
/** Entries persisting a provably-impossible duration (>6h / negative). */
|
|
287
|
+
implausible_duration: number;
|
|
288
|
+
/** Entries with no commit attribution (the off-by-one bug's fingerprints). */
|
|
289
|
+
missing_commit: number;
|
|
290
|
+
/** Sum of the above — the number CI should treat as "must be 0". */
|
|
291
|
+
total: number;
|
|
292
|
+
/** Running counter of entries scanned. */
|
|
293
|
+
scanned: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Count audit-health problems so CI can fail when the ledger is untrustworthy.
|
|
298
|
+
*
|
|
299
|
+
* These are exactly the artefacts of the two telemetry bugs we fixed: epoch
|
|
300
|
+
* durations (implausible_duration) and misattributed/blank commits
|
|
301
|
+
* (missing_commit). A clean ledger returns total=0.
|
|
302
|
+
*
|
|
303
|
+
* `since` (ISO-8601) scopes the check to entries at/after that instant. Both
|
|
304
|
+
* bugs predate the fix and left ~900 historical rows in the ledger; a CI gate
|
|
305
|
+
* that counted them would be permanently red. Scoping by the fix date turns
|
|
306
|
+
* the gate into "no NEW anomalies", which is exactly what we can enforce.
|
|
307
|
+
*/
|
|
308
|
+
export function computeAnomalies(repoRoot: string = process.cwd(), since?: string): AuditAnomalies {
|
|
309
|
+
const logPath = join(repoRoot, AUDIT_DIR, AUDIT_FILE);
|
|
310
|
+
const entries = parseAuditEntries(logPath);
|
|
311
|
+
let flagged = 0;
|
|
312
|
+
let null_with_reason = 0;
|
|
313
|
+
let implausible = 0;
|
|
314
|
+
let missingCommit = 0;
|
|
315
|
+
let considered = 0;
|
|
316
|
+
for (const e of entries) {
|
|
317
|
+
if (since && typeof e.timestamp === 'string' && e.timestamp < since) continue;
|
|
318
|
+
considered += 1;
|
|
319
|
+
if (e.duration_anomaly === true) flagged += 1;
|
|
320
|
+
if (e.duration_ms === null && e.duration_reason) null_with_reason += 1;
|
|
321
|
+
if (typeof e.duration_ms === 'number'
|
|
322
|
+
&& (!Number.isFinite(e.duration_ms) || e.duration_ms < 0 || e.duration_ms > MAX_PLAUSIBLE_DURATION_MS)) {
|
|
323
|
+
implausible += 1;
|
|
324
|
+
}
|
|
325
|
+
if (!e.commit_hash || e.commit_hash === 'unknown') missingCommit += 1;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
flagged,
|
|
329
|
+
null_with_reason,
|
|
330
|
+
implausible_duration: implausible,
|
|
331
|
+
missing_commit: missingCommit,
|
|
332
|
+
total: implausible + missingCommit,
|
|
333
|
+
scanned: considered,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Entries at/after this instant are expected to be clean. It is the date the
|
|
339
|
+
* two telemetry bugs were fixed; anything older is grandfathered because it
|
|
340
|
+
* cannot be re-measured. Keep in sync with the fix commit if you regenerate.
|
|
341
|
+
*/
|
|
342
|
+
export const AUDIT_ANOMALY_SINCE = '2026-10-30T00:00:00.000Z';
|
|
343
|
+
|
|
344
|
+
|
|
205
345
|
/**
|
|
206
346
|
* Rotate the audit log if it exceeds MAX_FILE_BYTES.
|
|
207
347
|
* Renames current file to .1, shifts existing archives.
|
|
@@ -250,24 +390,48 @@ function runCli(): void {
|
|
|
250
390
|
|
|
251
391
|
if (command === 'record') {
|
|
252
392
|
const opts = parseCliOptions(args.slice(1));
|
|
393
|
+
// Writer-side backstop: never persist an implausible duration (epoch-sized
|
|
394
|
+
// `end - 0`, unit error). Any caller-supplied reason wins; otherwise a
|
|
395
|
+
// rejected number is recorded as null + poisoned_start_ms.
|
|
396
|
+
const rawDuration = opts['duration-ms'] === 'null' ? null : parseInt(opts['duration-ms'] || '0', 10);
|
|
397
|
+
const durationCheck = sanitizeDurationMs(rawDuration);
|
|
398
|
+
const durationReason = opts['duration-reason'] || durationCheck.duration_reason;
|
|
253
399
|
const entry: GateAuditEntry = {
|
|
254
400
|
timestamp: new Date().toISOString(),
|
|
255
401
|
gate_id: opts['gate-id'] || 'unknown',
|
|
256
402
|
gate_name: opts['gate-name'] || 'unknown',
|
|
257
403
|
passed: opts['passed'] === 'true',
|
|
258
404
|
issues_found: parseInt(opts['issues-found'] || '0', 10),
|
|
259
|
-
duration_ms:
|
|
405
|
+
duration_ms: durationCheck.duration_ms,
|
|
260
406
|
trigger: (opts['trigger'] as 'commit' | 'push' | 'manual') || 'manual',
|
|
261
407
|
repo_path: process.cwd(),
|
|
262
|
-
|
|
408
|
+
// Pre-commit runs BEFORE the commit object exists, so `git rev-parse HEAD`
|
|
409
|
+
// here resolves the PARENT. resolveCommitHash lets an explicit
|
|
410
|
+
// --commit-hash win and falls back to HEAD otherwise.
|
|
411
|
+
commit_hash: resolveCommitHash(opts),
|
|
263
412
|
// Issue #370: only set when explicitly flagged (omit field otherwise)
|
|
264
413
|
...(opts['duration-anomaly'] === 'true' ? { duration_anomaly: true } : {}),
|
|
265
414
|
// REQ-B: why the duration is null — never guess a number for it.
|
|
266
|
-
...(
|
|
415
|
+
...(durationReason ? { duration_reason: durationReason as 'poisoned_start_ms' | 'clock_skew' } : {}),
|
|
267
416
|
// #507: only set when provided (omit field otherwise)
|
|
268
417
|
...(opts['detail'] ? { detail: opts['detail'] } : {}),
|
|
269
418
|
};
|
|
270
|
-
|
|
419
|
+
// Pre-commit (trigger=commit) is staging for a commit that does not exist
|
|
420
|
+
// yet: park it in the pending sidecar so post-commit can stamp the real
|
|
421
|
+
// hash. Its commit_hash is deliberately blank until then — stamping the
|
|
422
|
+
// parent here is exactly the off-by-one we are removing.
|
|
423
|
+
if (entry.trigger === 'commit' && !opts['commit-hash']) {
|
|
424
|
+
entry.commit_hash = '';
|
|
425
|
+
stageAuditEntry(entry);
|
|
426
|
+
} else {
|
|
427
|
+
appendAuditEntry(entry);
|
|
428
|
+
}
|
|
429
|
+
} else if (command === 'flush-pending') {
|
|
430
|
+
// Called by post-commit with the now-real HEAD.
|
|
431
|
+
const opts = parseCliOptions(args.slice(1));
|
|
432
|
+
const n = flushPendingAudit(process.cwd(), opts['commit-hash'] || '');
|
|
433
|
+
// Quiet by default (hooks are noisy enough); --report prints the count.
|
|
434
|
+
if (opts['report'] === 'true') console.log(`flushed ${n}`);
|
|
271
435
|
} else {
|
|
272
436
|
console.error(`Unknown audit command: ${command}`);
|
|
273
437
|
process.exit(1);
|
|
@@ -297,3 +461,47 @@ export function getCommitHash(): string {
|
|
|
297
461
|
return 'unknown';
|
|
298
462
|
}
|
|
299
463
|
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Which commit a gate entry belongs to.
|
|
467
|
+
*
|
|
468
|
+
* Pre-commit fires BEFORE the commit object exists, so `git rev-parse HEAD`
|
|
469
|
+
* there returns the PARENT. If the hook let that stand, every gate entry would
|
|
470
|
+
* be filed under the previous commit — the newest commit would look like it
|
|
471
|
+
* "ran no gates", and any per-commit battery analysis silently mixes one
|
|
472
|
+
* commit's timings into another's. An explicit `--commit-hash` (from a caller
|
|
473
|
+
* that knows the subject, e.g. post-commit) always wins; otherwise HEAD is the
|
|
474
|
+
* subject (push/manual) and the resolver is used.
|
|
475
|
+
*
|
|
476
|
+
* `headResolver` is injectable so the precedence can be tested without a repo.
|
|
477
|
+
*/
|
|
478
|
+
export function resolveCommitHash(
|
|
479
|
+
opts: Record<string, string>,
|
|
480
|
+
headResolver: () => string = getCommitHash,
|
|
481
|
+
): string {
|
|
482
|
+
const explicit = (opts['commit-hash'] || '').trim();
|
|
483
|
+
if (explicit) return explicit;
|
|
484
|
+
return headResolver();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Duration sanity (last line of defence) ───────────────────────────────────
|
|
488
|
+
// The shell side sanitizes start_ms before computing the delta
|
|
489
|
+
// (githooks/lib/audit-sanitize.sh, REQ-B). This is the writer-side backstop:
|
|
490
|
+
// if any caller path ever hands us a duration that is provably not a real
|
|
491
|
+
// gate runtime, refuse to persist it as a number. An epoch-sized value (the
|
|
492
|
+
// old bug produced `end - 0 = now`, ~1.7e12) or anything absurdly large would
|
|
493
|
+
// otherwise poison every average and p90 downstream and looks exactly like a
|
|
494
|
+
// "78-hour gate". Precision matters less than honesty: a rejected duration is
|
|
495
|
+
// null with a reason, never a fabricated number.
|
|
496
|
+
|
|
497
|
+
export function sanitizeDurationMs(
|
|
498
|
+
raw: number | null,
|
|
499
|
+
): { duration_ms: number | null; duration_reason?: 'poisoned_start_ms' } {
|
|
500
|
+
if (raw === null) return { duration_ms: null };
|
|
501
|
+
if (!Number.isFinite(raw) || raw < 0 || raw > MAX_PLAUSIBLE_DURATION_MS) {
|
|
502
|
+
// >6h or negative can only come from a poisoned start_ms (epoch-sized
|
|
503
|
+
// value, unit error) — mark it, don't guess a number.
|
|
504
|
+
return { duration_ms: null, duration_reason: 'poisoned_start_ms' };
|
|
505
|
+
}
|
|
506
|
+
return { duration_ms: raw };
|
|
507
|
+
}
|
package/lib/shared-utils.js
CHANGED
|
@@ -34,7 +34,10 @@ function readXpGateConfig() {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function copyHooks(srcDir, destDir) {
|
|
37
|
-
|
|
37
|
+
// post-commit carries the audit-attribution flush and --no-verify detection.
|
|
38
|
+
// It was absent here, so neither ever ran for installed users (the source
|
|
39
|
+
// existed but no installer copied it).
|
|
40
|
+
['pre-commit', 'pre-push', 'post-commit', 'post-merge'].forEach(hook => {
|
|
38
41
|
const src = path.join(srcDir, 'hooks', hook);
|
|
39
42
|
const dest = path.join(destDir, hook);
|
|
40
43
|
if (fs.existsSync(src)) {
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 3c5e9aaf
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.33.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 3c5e9aaf
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.33.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
|
|
6
6
|
"author": {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 3c5e9aaf
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.33.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/SPRINT-FLOW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** 3c5e9aaf
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.
|
|
6
|
+
**Version:** 0.33.0.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
**6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
|