@boyingliu01/xp-gate 0.35.0 → 0.35.2
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/bin/xp-gate.js +23 -0
- package/hooks/pre-commit +92 -0
- package/lib/__tests__/doctor-drift-direction.test.js +104 -0
- package/lib/__tests__/gate-coverage.test.js +137 -0
- package/lib/__tests__/rework.test.js +127 -0
- package/lib/__tests__/test-alignment.test.ts +45 -4
- package/lib/doctor.js +82 -8
- package/lib/gate-audit.ts +8 -0
- package/lib/gate-coverage.js +315 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/rework.js +262 -0
- package/lib/test-alignment.ts +27 -7
- 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/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -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/test-specification-alignment/AGENTS.md +2 -2
- 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/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/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/scripts/__tests__/sync-package-content-isolation.test.js +73 -0
- package/scripts/__tests__/sync-package-content.test.js +109 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js
CHANGED
|
@@ -350,6 +350,29 @@ const COMMANDS = {
|
|
|
350
350
|
},
|
|
351
351
|
usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
|
|
352
352
|
},
|
|
353
|
+
'gate-coverage': {
|
|
354
|
+
description: 'One view of compliance against every gate: PASS / DEBT / SKIP / FAIL, with debt and evidence (#636)',
|
|
355
|
+
run: subargs => {
|
|
356
|
+
const { main } = require('../lib/gate-coverage.js');
|
|
357
|
+
const { execFileSync } = require('node:child_process');
|
|
358
|
+
let headCommit;
|
|
359
|
+
try {
|
|
360
|
+
headCommit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
361
|
+
} catch {
|
|
362
|
+
headCommit = undefined;
|
|
363
|
+
}
|
|
364
|
+
process.exit(main(subargs, { headCommit }));
|
|
365
|
+
},
|
|
366
|
+
usage: 'xp-gate gate-coverage [--json] [--debt] [--strict] [--branch <name>]'
|
|
367
|
+
},
|
|
368
|
+
'rework': {
|
|
369
|
+
description: 'Report rework: first-pass rate, rounds before landing, top blocking reasons (#651)',
|
|
370
|
+
run: subargs => {
|
|
371
|
+
const { main } = require('../lib/rework.js');
|
|
372
|
+
process.exit(main(subargs));
|
|
373
|
+
},
|
|
374
|
+
usage: 'xp-gate rework [--top N] [--dir <path>] [--json]'
|
|
375
|
+
},
|
|
353
376
|
'crap': {
|
|
354
377
|
description: 'Rank functions by CRAP score (complexity x weak coverage) — advisory, never blocks (#404)',
|
|
355
378
|
run: subargs => {
|
package/hooks/pre-commit
CHANGED
|
@@ -627,6 +627,98 @@ record_gate_audit() {
|
|
|
627
627
|
disown 2>/dev/null || true
|
|
628
628
|
}
|
|
629
629
|
|
|
630
|
+
# ── #651: journal a blocking exit ──────────────────────────────────────────
|
|
631
|
+
#
|
|
632
|
+
# Failure was invisible in this repo's own history: audit.jsonl had 8,258 records
|
|
633
|
+
# and essentially zero gate FAILs, because pre-commit has ~76 `exit 1` blocking
|
|
634
|
+
# paths and record_gate_audit() is only reached AFTER a gate settles. The moment a
|
|
635
|
+
# gate FAILs and exits, the record that would have said so is never written — so
|
|
636
|
+
# `xp-gate retro` and `sprint-status --rework-check` computed rework over a
|
|
637
|
+
# denominator with no failures in it, and rework trended to zero by construction.
|
|
638
|
+
#
|
|
639
|
+
# The trap catches the failure on its way OUT rather than adding a record call to
|
|
640
|
+
# all 76 branches, which would rot the first time someone adds branch 77.
|
|
641
|
+
_BLOCKING_RECORDED=""
|
|
642
|
+
_record_blocking_exit() {
|
|
643
|
+
local rc=$?
|
|
644
|
+
# 0 = success, nothing to journal.
|
|
645
|
+
[ "$rc" -eq 0 ] && return 0
|
|
646
|
+
# One record per run. Without the latch, a gate that already recorded and then
|
|
647
|
+
# exited non-zero would be counted twice, halving the reported first-pass rate.
|
|
648
|
+
[ -n "$_BLOCKING_RECORDED" ] && return 0
|
|
649
|
+
_BLOCKING_RECORDED="1"
|
|
650
|
+
|
|
651
|
+
# Identify the gate that was running. Status vars are NO*-gradeable: any of
|
|
652
|
+
# FAIL/BLOCK means this gate is the reason. Latest failing gate wins, which is
|
|
653
|
+
# the one that actually called exit.
|
|
654
|
+
local _bg_id="" _bg_name="" _bg_status="" _bg_detail=""
|
|
655
|
+
local _i _st_var _st
|
|
656
|
+
for _i in 0 1 2 3 4 5 6 7 8 9 10 11 12 13; do
|
|
657
|
+
_st_var="GATE_${_i}_STATUS"
|
|
658
|
+
_st="${!_st_var:-}"
|
|
659
|
+
case "$_st" in
|
|
660
|
+
FAIL|BLOCK)
|
|
661
|
+
# Latest failing gate wins: it is the one that actually called exit.
|
|
662
|
+
_bg_id="$_i"
|
|
663
|
+
_bg_status="$_st"
|
|
664
|
+
;;
|
|
665
|
+
esac
|
|
666
|
+
done
|
|
667
|
+
local _bg_detail_var="GATE_${_bg_id}_DETAIL"
|
|
668
|
+
_bg_detail="${!_bg_detail_var:-}"
|
|
669
|
+
|
|
670
|
+
# Gate names for the journal. Deliberately short: this runs during shutdown.
|
|
671
|
+
case "$_bg_id" in
|
|
672
|
+
0) _bg_name="Version Consistency" ;;
|
|
673
|
+
1) _bg_name="Code Quality" ;;
|
|
674
|
+
2) _bg_name="Duplicate Code" ;;
|
|
675
|
+
3) _bg_name="Cyclomatic Complexity" ;;
|
|
676
|
+
4) _bg_name="Principles" ;;
|
|
677
|
+
5) _bg_name="Tests + Coverage" ;;
|
|
678
|
+
6) _bg_name="Architecture + Boy Scout" ;;
|
|
679
|
+
7) _bg_name="IaC Security" ;;
|
|
680
|
+
8) _bg_name="Secret Scanning" ;;
|
|
681
|
+
9) _bg_name="Build Integrity" ;;
|
|
682
|
+
10) _bg_name="SAST Security" ;;
|
|
683
|
+
11) _bg_name="Sprint Flow" ;;
|
|
684
|
+
12) _bg_name="File Hygiene" ;;
|
|
685
|
+
13) _bg_name="Compiler Strictness" ;;
|
|
686
|
+
*) _bg_name="unknown" ;;
|
|
687
|
+
esac
|
|
688
|
+
[ -n "$_bg_id" ] || _bg_id="unknown"
|
|
689
|
+
|
|
690
|
+
# The reason must reach the journal or "top-N failing rules" cannot exist.
|
|
691
|
+
# Prefer the gate's own detail (it carries the rule/threshold), else the status.
|
|
692
|
+
local _bg_reason="${_bg_detail:-gate_exit_${rc}}"
|
|
693
|
+
|
|
694
|
+
local _bg_start_var="GATE_${_bg_id}_START"
|
|
695
|
+
local _bg_start="${!_bg_start_var:-}"
|
|
696
|
+
local _bg_dur="null"
|
|
697
|
+
if [ -n "$_bg_start" ]; then
|
|
698
|
+
local _bg_end
|
|
699
|
+
_bg_end=$(now_ms 2>/dev/null || echo "")
|
|
700
|
+
if [ -n "$_bg_end" ]; then
|
|
701
|
+
_bg_dur=$((_bg_end - _bg_start))
|
|
702
|
+
[ "$_bg_dur" -ge 0 ] 2>/dev/null || _bg_dur="null"
|
|
703
|
+
fi
|
|
704
|
+
fi
|
|
705
|
+
|
|
706
|
+
# Fire-and-forget, and never let journaling change the exit code.
|
|
707
|
+
{
|
|
708
|
+
run_tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
|
|
709
|
+
--gate-id "$_bg_id" \
|
|
710
|
+
--gate-name "$_bg_name" \
|
|
711
|
+
--passed false \
|
|
712
|
+
--issues-found 1 \
|
|
713
|
+
--duration-ms "$_bg_dur" \
|
|
714
|
+
--trigger commit \
|
|
715
|
+
--failure-reason "$_bg_reason" \
|
|
716
|
+
2>/dev/null
|
|
717
|
+
} >/dev/null 2>&1 || true
|
|
718
|
+
return 0
|
|
719
|
+
}
|
|
720
|
+
trap _record_blocking_exit EXIT
|
|
721
|
+
|
|
630
722
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
631
723
|
echo " QUALITY GATES - PRE-COMMIT CHECK"
|
|
632
724
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-670 doctor: classify module drift by DIRECTION, not by line counts
|
|
3
|
+
* @intent `xp-gate doctor` used to report five gate modules as
|
|
4
|
+
*
|
|
5
|
+
* diverged both ways — githooks/ has 70 line(s) the install lacks,
|
|
6
|
+
* the install has 2 line(s) githooks/ lacks; sync refuses without an
|
|
7
|
+
* explicit decision
|
|
8
|
+
*
|
|
9
|
+
* I checked every "<" line (installed-only) by hand: all of them were
|
|
10
|
+
* OLDER versions of code the repository had already rewritten. The
|
|
11
|
+
* "install has 2 lines githooks lacks" was purely the shape a re-wrapped
|
|
12
|
+
* line leaves in a line-level diff — the same logic, with a `&&` or a
|
|
13
|
+
* pipe moved onto a new line, produces a line present on neither side.
|
|
14
|
+
*
|
|
15
|
+
* The wording matters more than it looks. "diverged both ways" plus
|
|
16
|
+
* "sync refuses without an explicit decision" tells the reader that both
|
|
17
|
+
* sides hold something worth keeping and a human must choose. The
|
|
18
|
+
* consequence is not extra keystrokes: it makes people AVOID syncing,
|
|
19
|
+
* because syncing appears to discard the installed copy's work. And not
|
|
20
|
+
* syncing is exactly #451 — editing githooks/ and having nothing happen.
|
|
21
|
+
*
|
|
22
|
+
* So the classifier must ask "does the installed copy hold anything the
|
|
23
|
+
* repo genuinely lacks?" instead of "are both line counts non-zero?".
|
|
24
|
+
* Token-level comparison answers the real question: whitespace and line
|
|
25
|
+
* breaks are formatting, and formatting is not work.
|
|
26
|
+
* @covers AC-670-64, AC-670-65, AC-670-66
|
|
27
|
+
*/
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const { moduleDriftDetail } = require('../doctor');
|
|
31
|
+
|
|
32
|
+
/** Join lines the way a formatter re-wraps them: same tokens, different breaks. */
|
|
33
|
+
const wrap = (...lines) => lines.join('\n');
|
|
34
|
+
|
|
35
|
+
describe('moduleDriftDetail direction classification (#667)', () => {
|
|
36
|
+
it('AC-670-64: a pure re-wrap is "behind", not "diverged both ways"', () => {
|
|
37
|
+
// Same logic; the `&&` moved to the next line. A line-level diff calls this
|
|
38
|
+
// two-sided drift. Nothing was authored on the installed side.
|
|
39
|
+
// Real shape measured on gate-3.sh: an older install re-wrapped the same
|
|
40
|
+
// statements, so a line-level diff reports lines unique to BOTH sides while
|
|
41
|
+
// the token content is identical (measured: repoOnly=0, installedOnly=0).
|
|
42
|
+
const repo = wrap(
|
|
43
|
+
'RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"',
|
|
44
|
+
'if [ -f "$RATCHET_LIB" ]; then . "$RATCHET_LIB"; fi',
|
|
45
|
+
'known.set(norm(f.file) + "::" + f.name, Number(f.ccn) || 0);',
|
|
46
|
+
);
|
|
47
|
+
const installed = wrap(
|
|
48
|
+
' RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"',
|
|
49
|
+
' if [ -f "$RATCHET_LIB" ]; then',
|
|
50
|
+
' . "$RATCHET_LIB"',
|
|
51
|
+
' fi',
|
|
52
|
+
'known.set(norm(f.file) + "::" + f.name,',
|
|
53
|
+
' Number(f.ccn) || 0);',
|
|
54
|
+
);
|
|
55
|
+
const detail = moduleDriftDetail(repo, installed);
|
|
56
|
+
expect(detail).not.toMatch(/diverged both ways/i);
|
|
57
|
+
expect(detail).toMatch(/formatting|stale|behind/i);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('AC-670-65: a genuine two-sided edit still refuses to sync silently', () => {
|
|
61
|
+
// The installed copy carries a real, machine-only hardening: a fail-closed
|
|
62
|
+
// branch that exists nowhere in the repo. Overwriting it would destroy work.
|
|
63
|
+
const repo = wrap(
|
|
64
|
+
'if [ -z "$COV" ]; then',
|
|
65
|
+
' echo "no coverage tool";',
|
|
66
|
+
' return 0;',
|
|
67
|
+
'fi',
|
|
68
|
+
);
|
|
69
|
+
const installed = wrap(
|
|
70
|
+
'if [ -z "$COV" ]; then',
|
|
71
|
+
' # FAIL CLOSED: cannot determine coverage means the check failed.',
|
|
72
|
+
' echo "❌ BLOCKED - could not determine coverage";',
|
|
73
|
+
' return 1;',
|
|
74
|
+
'fi',
|
|
75
|
+
);
|
|
76
|
+
const detail = moduleDriftDetail(repo, installed);
|
|
77
|
+
expect(detail).toMatch(/diverged both ways/i);
|
|
78
|
+
expect(detail).toMatch(/refuses|explicit decision/i);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('AC-670-66: an unreadable pair is reported honestly, never as identical', () => {
|
|
82
|
+
// readPair() returns null when either side cannot be read. Reporting that as
|
|
83
|
+
// "matches" would be a fail-open: the drift could be real and unseen.
|
|
84
|
+
const detail = moduleDriftDetail(null, null);
|
|
85
|
+
expect(detail).toMatch(/could not be read|cannot be compared/i);
|
|
86
|
+
expect(detail).not.toMatch(/matches|identical/i);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('re-wrap is not drift (#667)', () => {
|
|
91
|
+
it('AC-670-64: a pure re-wrap yields no unique tokens on either side', () => {
|
|
92
|
+
// The end-to-end property: formatting-only differences must not count as an
|
|
93
|
+
// issue, because failing on them teaches the reader to distrust `doctor`
|
|
94
|
+
// and to skip the sync that #451 exists to encourage.
|
|
95
|
+
const { tokenDelta } = require('../doctor.js');
|
|
96
|
+
const NL = String.fromCharCode(10);
|
|
97
|
+
const repo = 'a &&' + NL + ' b' + NL;
|
|
98
|
+
const installed = 'a' + NL + ' && b' + NL;
|
|
99
|
+
const delta = tokenDelta(repo, installed);
|
|
100
|
+
expect(delta.repoOnly).toBe(0);
|
|
101
|
+
expect(delta.installedOnly).toBe(0);
|
|
102
|
+
expect(moduleDriftDetail(repo, installed)).toMatch(/formatting/i);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-636 One command that shows compliance against EVERY gate
|
|
3
|
+
* @intent Today the answer to "how healthy is this project against xp-gate's
|
|
4
|
+
* gates" takes six commands and mental arithmetic:
|
|
5
|
+
*
|
|
6
|
+
* xp-gate gate-list && xp-gate check . --all && xp-gate doctor
|
|
7
|
+
* node scripts/ccn-report.cjs --check
|
|
8
|
+
* node -e "...sum .warnings-baseline.json..."
|
|
9
|
+
* gh run list
|
|
10
|
+
*
|
|
11
|
+
* Every existing command answers "did THIS gate pass". None answers
|
|
12
|
+
* "where is the debt and what should I fix first".
|
|
13
|
+
*
|
|
14
|
+
* The single most important property is that the view has FOUR states,
|
|
15
|
+
* not three. `passed / skipped / failed` merges "did not run" into
|
|
16
|
+
* "ran and was fine", and that is exactly where technical debt hides.
|
|
17
|
+
* A gate that never executed renders identically to a gate that passed
|
|
18
|
+
* clean -- a false green, which is the most dangerous output this
|
|
19
|
+
* tooling can produce.
|
|
20
|
+
*
|
|
21
|
+
* Two data hazards drive the rest of the design:
|
|
22
|
+
*
|
|
23
|
+
* - The producer artifact numbers its gates `gate9_build_integrity` and
|
|
24
|
+
* `gate10_sast`, but the CLI calls Build Integrity Gate 10 and SAST
|
|
25
|
+
* Gate 9 -- the two are SWAPPED. Rendering the artifact naively shows
|
|
26
|
+
* the user the wrong gate name, which is worse than showing nothing.
|
|
27
|
+
* The mapping is therefore keyed on the artifact's own taxonomy and
|
|
28
|
+
* pinned by a test.
|
|
29
|
+
*
|
|
30
|
+
* - `quality-status/<branch>.json` records the commit it was generated
|
|
31
|
+
* from. Rendering it for a different HEAD silently presents stale
|
|
32
|
+
* results as current. This repo has had that artifact sit at an old
|
|
33
|
+
* commit for days, so staleness must be explicit, not inferred away.
|
|
34
|
+
* @covers AC-636-01, AC-636-02, AC-636-03, AC-636-04, AC-636-05, AC-636-06
|
|
35
|
+
*/
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
const { renderGateCoverage, buildCoverageRows, STATUS } = require('../gate-coverage');
|
|
39
|
+
|
|
40
|
+
/** A `quality-status` artifact shaped like the real producer's output. */
|
|
41
|
+
function artifact(overrides) {
|
|
42
|
+
return {
|
|
43
|
+
reportVersion: '2.0',
|
|
44
|
+
generatedAt: '2026-10-27T03:06:05Z',
|
|
45
|
+
commit: 'a0bceb9c0000000000000000000000000000000',
|
|
46
|
+
branch: 'main',
|
|
47
|
+
language: 'typescript iac',
|
|
48
|
+
overall: {
|
|
49
|
+
gatesPassed: 6,
|
|
50
|
+
gatesTotal: 12,
|
|
51
|
+
gatesSkipped: 6,
|
|
52
|
+
gatesWarned: 0,
|
|
53
|
+
gatesFailed: 0,
|
|
54
|
+
gatesEffective: 6,
|
|
55
|
+
effectivePassRate: 100,
|
|
56
|
+
scoreAllGates: 5,
|
|
57
|
+
score: 10,
|
|
58
|
+
verdict: 'PARTIAL',
|
|
59
|
+
...(overrides && overrides.overall ? overrides.overall : {}),
|
|
60
|
+
},
|
|
61
|
+
gates: {
|
|
62
|
+
gate1_static_analysis: { name: 'Static Analysis', status: 'PASS', tool: 'eslint' },
|
|
63
|
+
gate9_build_integrity: { name: 'Build Integrity', status: 'PASS', tool: 'tsc' },
|
|
64
|
+
gate10_sast: { name: 'SAST Security', status: 'PASS', tool: 'semgrep' },
|
|
65
|
+
...(overrides && overrides.gates ? overrides.gates : {}),
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('gate coverage — four states, never a false green (#636)', () => {
|
|
71
|
+
it('AC-636-01: a gate that never ran is SKIP with a reason, never folded into PASS', () => {
|
|
72
|
+
const rows = buildCoverageRows(artifact(), { headCommit: 'a0bceb9c0000000000000000000000000000000' });
|
|
73
|
+
// Gates 0/1/5/11 are hook-only: they exist but the CLI cannot run them.
|
|
74
|
+
const version = rows.find((r) => r.gate === 0);
|
|
75
|
+
expect(version, 'Gate 0 must appear even though it never ran').toBeDefined();
|
|
76
|
+
expect(version.status).toBe(STATUS.SKIP);
|
|
77
|
+
// A bare SKIP is the false green. The reason is mandatory.
|
|
78
|
+
expect(version.reason).toBeTruthy();
|
|
79
|
+
// The reason must say WHY it did not run -- "hook only" and "not run here"
|
|
80
|
+
// are both honest; a bare SKIP is not.
|
|
81
|
+
expect(version.reason).toMatch(/hook|git commit|not run/i);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('AC-636-02: the artifact gate-number swap does not mislabel the gates', () => {
|
|
85
|
+
// The producer says gate9_* = Build Integrity; the CLI says Gate 10 is Build
|
|
86
|
+
// Integrity. Rendering the artifact's own key would show SAST as Gate 10.
|
|
87
|
+
const rows = buildCoverageRows(artifact(), { headCommit: 'a0bceb9c0000000000000000000000000000000' });
|
|
88
|
+
const build = rows.find((r) => r.name === 'Build Integrity');
|
|
89
|
+
const sast = rows.find((r) => r.name === 'SAST Security');
|
|
90
|
+
expect(build, 'Build Integrity row must exist').toBeDefined();
|
|
91
|
+
expect(sast, 'SAST row must exist').toBeDefined();
|
|
92
|
+
// Both rows exist and carry the CLI's numbering, not the artifact's.
|
|
93
|
+
expect(build.gate).toBe(10);
|
|
94
|
+
expect(sast.gate).toBe(9);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('AC-636-03: a stale artifact is marked STALE instead of presented as current', () => {
|
|
98
|
+
const rows = buildCoverageRows(artifact(), { headCommit: 'ffffffffffffffffffffffffffffffffffffffff' });
|
|
99
|
+
const rendered = renderGateCoverage(rows, { stale: true, headCommit: 'ffffffffffffffffffffffffffffffffffffffff' });
|
|
100
|
+
expect(rendered).toMatch(/STALE/i);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('AC-636-04: DEBT is observable and quantified, not rendered as a clean PASS', () => {
|
|
104
|
+
// Gate 6 holds no warnings of its own; the debt lives in the Boy Scout
|
|
105
|
+
// baseline. A view that only reads gate statuses reports "PASS" and hides it.
|
|
106
|
+
const rows = buildCoverageRows(artifact(), {
|
|
107
|
+
headCommit: 'a0bceb9c0000000000000000000000000000000',
|
|
108
|
+
boyScout: { files: 14, warnings: 129 },
|
|
109
|
+
});
|
|
110
|
+
const boyScout = rows.find((r) => /boy scout/i.test(r.name));
|
|
111
|
+
expect(boyScout).toBeDefined();
|
|
112
|
+
expect(boyScout.status).toBe(STATUS.DEBT);
|
|
113
|
+
expect(String(boyScout.debt)).toMatch(/129/);
|
|
114
|
+
expect(boyScout.evidence).toMatch(/warnings-baseline/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('AC-636-05: default exit does not fail on DEBT, but --strict does', () => {
|
|
118
|
+
const debtRows = [{ gate: 6, name: 'Boy Scout', status: STATUS.DEBT, debt: '1 warning' }];
|
|
119
|
+
expect(buildCoverageRows).toBeDefined();
|
|
120
|
+
const { exitCodeFor } = require('../gate-coverage');
|
|
121
|
+
// Debt is budgeted and known: failing on it by default makes the command
|
|
122
|
+
// unusable as a CI dashboard.
|
|
123
|
+
expect(exitCodeFor(debtRows, {})).toBe(0);
|
|
124
|
+
expect(exitCodeFor(debtRows, { strict: true })).toBe(1);
|
|
125
|
+
// A real failure always fails.
|
|
126
|
+
expect(exitCodeFor([{ gate: 7, name: 'x', status: STATUS.FAIL }], {})).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('AC-636-06: every row carries evidence pointing at the artifact that decided it', () => {
|
|
130
|
+
const rows = buildCoverageRows(artifact(), { headCommit: 'a0bceb9c0000000000000000000000000000000' });
|
|
131
|
+
// Without Evidence the user can only trust a one-line claim; with it they
|
|
132
|
+
// can go check. Same reason `doctor` prints paths.
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
expect(row.evidence, `Gate ${row.gate} (${row.name}) has no evidence pointer`).toBeTruthy();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-651 rework
|
|
3
|
+
* @intent #651 asks for a command that turns the gate journal into the three
|
|
4
|
+
* numbers rework management actually needs:
|
|
5
|
+
*
|
|
6
|
+
* 1. first-pass rate -- how often a round clears the gates on try 1
|
|
7
|
+
* 2. rework round stats -- median / mean / worst rounds per landing
|
|
8
|
+
* 3. top-N failing rules -- which rule_id blocks most often
|
|
9
|
+
*
|
|
10
|
+
* Without (3) the only thing you can do is "try harder". With it, the
|
|
11
|
+
* answer to "why is development slow" becomes a ranked list.
|
|
12
|
+
*
|
|
13
|
+
* Two data hazards decide the design:
|
|
14
|
+
*
|
|
15
|
+
* - Before #651 the journal contained NO failures, so every derived rate
|
|
16
|
+
* was 100% by construction. A command that reports "first-pass rate: 100%"
|
|
17
|
+
* from an empty failure set is worse than no command: it manufactures
|
|
18
|
+
* confidence. So an input with zero FAIL records must report that the
|
|
19
|
+
* figure is NOT MEASURABLE, not that it is perfect.
|
|
20
|
+
*
|
|
21
|
+
* - ~401 historical records carry epoch-sized `duration_ms` (flagged
|
|
22
|
+
* `duration_anomaly`). Any duration aggregate must exclude them rather
|
|
23
|
+
* than average them in -- one of them once produced "average 430 million
|
|
24
|
+
* seconds".
|
|
25
|
+
*
|
|
26
|
+
* These tests exercise the pure aggregation function, so the numbers are
|
|
27
|
+
* pinned independently of file layout.
|
|
28
|
+
* @covers AC-670-59, AC-670-60, AC-670-61, AC-670-62
|
|
29
|
+
*/
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const { computeReworkStats } = require('../rework');
|
|
33
|
+
|
|
34
|
+
/** One journal record, defaulting to a pass. */
|
|
35
|
+
function rec(overrides) {
|
|
36
|
+
return {
|
|
37
|
+
timestamp: '2026-10-30T10:00:00.000Z',
|
|
38
|
+
gate_id: '1',
|
|
39
|
+
gate_name: 'Code Quality',
|
|
40
|
+
passed: true,
|
|
41
|
+
issues_found: 0,
|
|
42
|
+
duration_ms: 1000,
|
|
43
|
+
trigger: 'commit',
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('computeReworkStats (#651)', () => {
|
|
49
|
+
it('AC-670-59: reports an unmeasurable first-pass rate when the journal has no failures', () => {
|
|
50
|
+
// This is exactly the pre-#651 journal: thousands of passes, zero fails.
|
|
51
|
+
const entries = Array.from({ length: 50 }, () => rec());
|
|
52
|
+
const stats = computeReworkStats(entries);
|
|
53
|
+
// A rate derived from an empty failure set is not 100% -- it is unknown.
|
|
54
|
+
expect(stats.firstPassRate).toBeNull();
|
|
55
|
+
expect(stats.measurable).toBe(false);
|
|
56
|
+
expect(stats.reason).toMatch(/no failure/i);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('AC-670-60: counts failing rules and ranks them, so the worst rule is actionable', () => {
|
|
60
|
+
const entries = [
|
|
61
|
+
rec({ passed: false, failure_reason: 'long-function' }),
|
|
62
|
+
rec({ passed: false, failure_reason: 'long-function' }),
|
|
63
|
+
rec({ passed: false, failure_reason: 'long-function' }),
|
|
64
|
+
rec({ passed: false, failure_reason: 'tests_failed' }),
|
|
65
|
+
rec(),
|
|
66
|
+
rec(),
|
|
67
|
+
];
|
|
68
|
+
const stats = computeReworkStats(entries);
|
|
69
|
+
expect(stats.measurable).toBe(true);
|
|
70
|
+
expect(stats.topFailures[0]).toEqual({ reason: 'long-function', count: 3 });
|
|
71
|
+
expect(stats.topFailures[1]).toEqual({ reason: 'tests_failed', count: 1 });
|
|
72
|
+
// 4 failures out of 6 attempts -> 33% first-pass.
|
|
73
|
+
expect(stats.firstPassRate).toBeCloseTo(33.3, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('AC-670-61: excludes anomaly-flagged durations instead of averaging them in', () => {
|
|
77
|
+
// A real epoch-sized value from the corpus; averaging it in produced
|
|
78
|
+
// "average 430 million seconds" on gate-1.
|
|
79
|
+
const entries = [
|
|
80
|
+
rec({ duration_ms: 1000 }),
|
|
81
|
+
rec({ duration_ms: 3000 }),
|
|
82
|
+
rec({ duration_ms: 1784536915891, duration_anomaly: true }),
|
|
83
|
+
];
|
|
84
|
+
const stats = computeReworkStats(entries);
|
|
85
|
+
expect(stats.duration).not.toBeNull();
|
|
86
|
+
// Median over the two sane values (1000, 3000) is 2000, not ~5.9e11.
|
|
87
|
+
expect(stats.duration.medianMs).toBe(2000);
|
|
88
|
+
expect(stats.duration.samples).toBe(2);
|
|
89
|
+
expect(stats.duration.excludedAnomalies).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('AC-670-62: a failure with no reason is bucketed, never silently dropped', () => {
|
|
93
|
+
const entries = [
|
|
94
|
+
rec({ passed: false }), // no failure_reason
|
|
95
|
+
rec({ passed: false, failure_reason: '' }), // empty
|
|
96
|
+
rec({ passed: false, failure_reason: 'short-circuit' }),
|
|
97
|
+
];
|
|
98
|
+
const stats = computeReworkStats(entries);
|
|
99
|
+
// Dropping unlabelled failures would understate the failure count and push
|
|
100
|
+
// the first-pass rate UP -- the optimistic direction is the dangerous one.
|
|
101
|
+
expect(stats.failures).toBe(3);
|
|
102
|
+
const unattributed = stats.topFailures.find((f) => /unattributed|unknown/i.test(f.reason));
|
|
103
|
+
expect(unattributed, 'unlabelled failures must appear in the ranking').toBeDefined();
|
|
104
|
+
expect(unattributed.count).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('rework duration sampling (#651)', () => {
|
|
109
|
+
it('AC-670-61: an UNFLAGGED epoch-sized duration is still excluded', () => {
|
|
110
|
+
// Measured on the real journal: 391 records carry duration_anomaly, but 6
|
|
111
|
+
// more epoch-sized values do not. Trusting the flag alone left the mean at
|
|
112
|
+
// 1.3e9 ms -- the "average 430 million seconds" artifact returning through
|
|
113
|
+
// the back door. Plausibility must be checked independently of the flag.
|
|
114
|
+
const entries = [
|
|
115
|
+
rec({ duration_ms: 2000 }),
|
|
116
|
+
rec({ duration_ms: 4000 }),
|
|
117
|
+
rec({ duration_ms: 1785001784992 }), // no duration_anomaly flag
|
|
118
|
+
];
|
|
119
|
+
const stats = computeReworkStats(entries);
|
|
120
|
+
expect(stats.duration).not.toBeNull();
|
|
121
|
+
expect(stats.duration.medianMs).toBe(3000);
|
|
122
|
+
expect(stats.duration.samples).toBe(2);
|
|
123
|
+
// Excluded count covers BOTH flagged and implausible, or the sample size
|
|
124
|
+
// looks closer to the record count than it really is.
|
|
125
|
+
expect(stats.duration.excludedAnomalies).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -263,7 +263,7 @@ test.each([['hello', 5], ['world', 5]])('%s has length %i', (str, len) => {
|
|
|
263
263
|
* @covers AC-SKIP-001-01
|
|
264
264
|
*/
|
|
265
265
|
it.skip('should parse skipped tests', () => {
|
|
266
|
-
expect(
|
|
266
|
+
expect(subject).toBe('fixture');
|
|
267
267
|
});
|
|
268
268
|
|
|
269
269
|
/**
|
|
@@ -292,7 +292,7 @@ test.todo('should parse todo tests');
|
|
|
292
292
|
*/
|
|
293
293
|
const featureName = 'Login';
|
|
294
294
|
it(\`should authenticate \${featureName} user\`, () => {
|
|
295
|
-
expect(
|
|
295
|
+
expect(subject).toBe('fixture');
|
|
296
296
|
});
|
|
297
297
|
`;
|
|
298
298
|
|
|
@@ -665,7 +665,7 @@ specification:
|
|
|
665
665
|
* @covers AC-PUNCT-001-01
|
|
666
666
|
*/
|
|
667
667
|
test('punctuation handling', () => {
|
|
668
|
-
expect(
|
|
668
|
+
expect(subject).toBe('fixture');
|
|
669
669
|
});
|
|
670
670
|
|
|
671
671
|
/**
|
|
@@ -674,7 +674,7 @@ test('punctuation handling', () => {
|
|
|
674
674
|
* @covers AC-PUNCT-002-01
|
|
675
675
|
*/
|
|
676
676
|
test('comma handling', () => {
|
|
677
|
-
expect(
|
|
677
|
+
expect(subject).toBe('fixture');
|
|
678
678
|
});
|
|
679
679
|
`;
|
|
680
680
|
|
|
@@ -796,3 +796,44 @@ specification:
|
|
|
796
796
|
expect(report.alignment_status).toBe('FAIL');
|
|
797
797
|
});
|
|
798
798
|
});
|
|
799
|
+
|
|
800
|
+
describe('writeReport error handling (Boy Scout debt, #641)', () => {
|
|
801
|
+
it('AC-670-63: surfaces a write failure instead of throwing out of the run', () => {
|
|
802
|
+
// writeReport() runs as the LAST step of runFullAlignment — after the spec is
|
|
803
|
+
// parsed and every test file is scanned. An unguarded throw here discards all
|
|
804
|
+
// of that work and turns a reportable condition (disk full, read-only mount,
|
|
805
|
+
// permission denied) into a crashed command with no report at all. The report
|
|
806
|
+
// schema already carries an `errors` array for exactly this.
|
|
807
|
+
const fs = require('node:fs');
|
|
808
|
+
const path = require('node:path');
|
|
809
|
+
const os = require('node:os');
|
|
810
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-wr-'));
|
|
811
|
+
// A path whose PARENT is a regular file cannot be created as a directory.
|
|
812
|
+
const blocker = path.join(tmp, 'blocker');
|
|
813
|
+
fs.writeFileSync(blocker, 'not a directory');
|
|
814
|
+
const outputPath = path.join(blocker, 'nested', 'report.json');
|
|
815
|
+
|
|
816
|
+
const report = {
|
|
817
|
+
alignment_status: 'FAIL' as const,
|
|
818
|
+
phase: 1,
|
|
819
|
+
score: 0,
|
|
820
|
+
head_commit: 'abc123',
|
|
821
|
+
spec_hash: sha256('x'),
|
|
822
|
+
timestamp: new Date().toISOString(),
|
|
823
|
+
misaligned_tests: [],
|
|
824
|
+
anti_pattern_detected: false,
|
|
825
|
+
errors: [] as string[],
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
try {
|
|
829
|
+
// Assert the OBSERVABLE outcome, not merely "did not throw": the contract is
|
|
830
|
+
// that the failure is reported through the report object the caller holds.
|
|
831
|
+
const written = writeReport(report, outputPath);
|
|
832
|
+
expect(written).toBe(false);
|
|
833
|
+
expect(report.errors.length).toBe(1);
|
|
834
|
+
expect(report.errors[0]).toContain('Failed to write report');
|
|
835
|
+
} finally {
|
|
836
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
});
|