@boyingliu01/xp-gate 0.35.9 → 0.35.11
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/hooks/pre-commit +96 -1
- package/lib/__tests__/coverage-scope.test.js +140 -0
- package/lib/__tests__/coverage-threshold.test.js +80 -0
- package/lib/__tests__/slo.test.js +89 -0
- package/lib/coverage-threshold.js +88 -0
- package/lib/gate-coverage.js +72 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +84 -0
- package/lib/slo.js +150 -0
- 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/sprint-flow/references/phase-3-build.md +14 -1
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -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/hooks/pre-commit
CHANGED
|
@@ -36,6 +36,30 @@ _LE_CR=$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]'); test "${_LE_CR:-0}" -e
|
|
|
36
36
|
# 1. Global: ~/.config/xp-gate/adapters (for core.hooksPath global setup)
|
|
37
37
|
# 2. Project-local: <repo>/githooks/ (for per-project init)
|
|
38
38
|
# 3. Script dir: hooks directory containing this script (fallback)
|
|
39
|
+
# ── #640: wall-clock start, written into the per-run report ──────────────────
|
|
40
|
+
# The gate journal records every gate's verdict but no timing, so a slow commit
|
|
41
|
+
# could not be attributed: the #640 measurement found ~11s per commit outside any
|
|
42
|
+
# gate and had no instrument to say where it went. One timestamp at entry and one
|
|
43
|
+
# subtraction at report-write time turns "it feels slow" into a number.
|
|
44
|
+
#
|
|
45
|
+
# Milliseconds come from /proc/uptime when readable (bash's %3N is not portable to
|
|
46
|
+
# the bash 3.2 / MSYS builds this hook supports); otherwise whole seconds, which
|
|
47
|
+
# is coarse but still separates "gates are slow" from "the hook around them is".
|
|
48
|
+
_XP_HOOK_START_MS=""
|
|
49
|
+
if [ -r /proc/uptime ]; then
|
|
50
|
+
_xp_up=$(cut -d' ' -f1 /proc/uptime 2>/dev/null || echo "")
|
|
51
|
+
case "$_xp_up" in
|
|
52
|
+
*.*) _XP_HOOK_START_MS=$(printf '%s%s' "${_xp_up%%.*}" "$(printf '%s' "${_xp_up#*.}" | cut -c1-3)") ;;
|
|
53
|
+
esac
|
|
54
|
+
fi
|
|
55
|
+
case "$_XP_HOOK_START_MS" in
|
|
56
|
+
''|*[!0-9]*) _XP_HOOK_START_MS=$(( $(date +%s) * 1000 )) ;;
|
|
57
|
+
esac
|
|
58
|
+
export _XP_HOOK_START_MS
|
|
59
|
+
# #609: 1-based attempt index for this hook run. One hook invocation is one attempt,
|
|
60
|
+
# so every gate recorded during it shares the same index. Left empty by default so a
|
|
61
|
+
# caller that genuinely does not know the index omits the field instead of guessing.
|
|
62
|
+
_XP_GATE_ATTEMPT=""
|
|
39
63
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
40
64
|
ADAPTER_DIR=""
|
|
41
65
|
GLOBAL_ADAPTER_DIR="$HOME/.config/xp-gate/adapters"
|
|
@@ -578,6 +602,41 @@ record_gate_audit() {
|
|
|
578
602
|
local detail="${6:-}"
|
|
579
603
|
local end_ms
|
|
580
604
|
end_ms=$(now_ms)
|
|
605
|
+
# #609: attempt index for this gate.
|
|
606
|
+
#
|
|
607
|
+
# Two failed designs are recorded here so they are not retried:
|
|
608
|
+
#
|
|
609
|
+
# 1. Keying on commit_hash -- the pre-commit record is STAGED with an empty
|
|
610
|
+
# commit_hash (the commit does not exist yet, see stageAuditEntry), so the
|
|
611
|
+
# lookup always matched zero and every gate stayed at attempt 1.
|
|
612
|
+
# 2. Counting every record in a time window -- measured attempt=63 for gate-1
|
|
613
|
+
# over one working session, because unrelated SUCCESSFUL commits accumulate.
|
|
614
|
+
# A retry index that counts successes is not a retry index.
|
|
615
|
+
#
|
|
616
|
+
# What actually defines a retry: the previous record for this gate FAILED. So the
|
|
617
|
+
# index is (consecutive failures immediately before this run) + 1. A clean history
|
|
618
|
+
# is attempt 1; fail, fix, re-commit gives 2; three failures in a row gives 4.
|
|
619
|
+
#
|
|
620
|
+
# Read failure leaves the value empty -> the writer omits the field rather than
|
|
621
|
+
# recording a fabricated 1, which would inflate the first-pass denominator.
|
|
622
|
+
if [ -z "${_XP_GATE_ATTEMPT:-}" ]; then
|
|
623
|
+
local _failed_streak
|
|
624
|
+
if [ -f .xp-gate/audit.jsonl ]; then
|
|
625
|
+
_failed_streak=$(awk -v g="\"gate_id\":\"${gate_id}\"" '
|
|
626
|
+
index($0, g) {
|
|
627
|
+
# passed is a JSON boolean; "false" is the only failure spelling.
|
|
628
|
+
if (index($0, "\"passed\":false") > 0) { streak++ }
|
|
629
|
+
else if (index($0, "\"passed\":true") > 0) { streak = 0 }
|
|
630
|
+
}
|
|
631
|
+
END { print streak + 0 }' .xp-gate/audit.jsonl 2>/dev/null | tr -d ' ')
|
|
632
|
+
case "$_failed_streak" in
|
|
633
|
+
''|*[!0-9]*) _failed_streak="" ;;
|
|
634
|
+
esac
|
|
635
|
+
if [ -n "$_failed_streak" ]; then
|
|
636
|
+
_XP_GATE_ATTEMPT=$(( _failed_streak + 1 ))
|
|
637
|
+
fi
|
|
638
|
+
fi
|
|
639
|
+
fi
|
|
581
640
|
# REQ-B: sanitize BEFORE subtracting. A poisoned start (empty -> arithmetic
|
|
582
641
|
# 0, or a seconds-unit value from an old code path) used to survive as an
|
|
583
642
|
# epoch-sized duration_ms that only the anomaly flag papered over. Now the
|
|
@@ -613,6 +672,13 @@ record_gate_audit() {
|
|
|
613
672
|
fi
|
|
614
673
|
|
|
615
674
|
# Fire-and-forget: audit must never block the commit
|
|
675
|
+
#
|
|
676
|
+
# #609: `--attempt` was implemented in gate-audit.ts (#603) but never passed by
|
|
677
|
+
# this hook -- verified against 9212 real journal entries, none carried `attempt`.
|
|
678
|
+
# The 1-based attempt index makes the first-build / final-build / first-test SLOs
|
|
679
|
+
# computable; without it "passed" cannot separate a clean run from one that only
|
|
680
|
+
# passed after a re-run, and every such metric is defined to be 100%. Callers that
|
|
681
|
+
# do not know the index stay absent (== 1) rather than being stamped with a guess.
|
|
616
682
|
{
|
|
617
683
|
run_tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
|
|
618
684
|
--gate-id "$gate_id" \
|
|
@@ -623,6 +689,7 @@ record_gate_audit() {
|
|
|
623
689
|
$duration_anomaly_flag \
|
|
624
690
|
$duration_reason_flag \
|
|
625
691
|
--trigger commit \
|
|
692
|
+
${_XP_GATE_ATTEMPT:+--attempt "$_XP_GATE_ATTEMPT"} \
|
|
626
693
|
${detail:+--detail "$detail"} \
|
|
627
694
|
2>/dev/null
|
|
628
695
|
} &
|
|
@@ -5071,7 +5138,35 @@ ENDJSON
|
|
|
5071
5138
|
REPORT_TS=$(date -u +"%Y-%m-%d-%H%M%S")
|
|
5072
5139
|
local PERSISTED_REPORT_FILE="${REPORTS_DIR}/${REPORT_TS}.json"
|
|
5073
5140
|
mkdir -p "$REPORTS_DIR"
|
|
5074
|
-
|
|
5141
|
+
# #640: `duration_ms` is wall-clock time from hook entry to report write. It is
|
|
5142
|
+
# the number the issue could not get: gate verdicts were journaled but durations
|
|
5143
|
+
# were not, so the ~11s observed outside any gate had no instrument pointing at
|
|
5144
|
+
# it. Emitted only when measurable -- an absent field is honest, a 0 is not.
|
|
5145
|
+
#
|
|
5146
|
+
# The subtraction happens in BASH, not in the node writer. Measured: on MSYS
|
|
5147
|
+
# (Windows) `/proc/uptime` is visible to bash but NOT to native node --
|
|
5148
|
+
# `readFileSync('/proc/uptime')` there throws ENOENT. Computing the elapsed time
|
|
5149
|
+
# in node therefore passed a bash-clock start into a reader that could not produce
|
|
5150
|
+
# a comparable "now", and the field silently never appeared. Both ends of a
|
|
5151
|
+
# duration must come from the same clock.
|
|
5152
|
+
local _XP_DURATION_MS=""
|
|
5153
|
+
local _XP_NOW_MS=""
|
|
5154
|
+
if [ -r /proc/uptime ]; then
|
|
5155
|
+
_xp_up2=$(cut -d' ' -f1 /proc/uptime 2>/dev/null || echo "")
|
|
5156
|
+
case "$_xp_up2" in
|
|
5157
|
+
*.*) _XP_NOW_MS=$(printf '%s%s' "${_xp_up2%%.*}" "$(printf '%s' "${_xp_up2#*.}" | cut -c1-3)") ;;
|
|
5158
|
+
esac
|
|
5159
|
+
fi
|
|
5160
|
+
case "$_XP_NOW_MS" in
|
|
5161
|
+
''|*[!0-9]*) _XP_NOW_MS="" ;;
|
|
5162
|
+
esac
|
|
5163
|
+
case "$_XP_HOOK_START_MS" in
|
|
5164
|
+
''|*[!0-9]*) _XP_HOOK_START_MS="" ;;
|
|
5165
|
+
esac
|
|
5166
|
+
if [ -n "$_XP_NOW_MS" ] && [ -n "$_XP_HOOK_START_MS" ] && [ "$_XP_NOW_MS" -gt "$_XP_HOOK_START_MS" ]; then
|
|
5167
|
+
_XP_DURATION_MS=$(( _XP_NOW_MS - _XP_HOOK_START_MS ))
|
|
5168
|
+
fi
|
|
5169
|
+
_XP_RF="$REPORT_FILE" _XP_PRF="$PERSISTED_REPORT_FILE" _XP_TS="$TIMESTAMP" _XP_BR="$BRANCH" _XP_CH="$COMMIT_HASH" _XP_CF="${CHANGED_FILES:-}" _XP_DUR="$_XP_DURATION_MS" node -e "const fs=require('fs');const e=process.env;const br=JSON.parse(fs.readFileSync(e._XP_RF,'utf8'));const cf=(e._XP_CF||'').split('\n').filter(f=>f);const r={timestamp:e._XP_TS,trigger:'pre-commit',branch:e._XP_BR,commit:e._XP_CH,changed_files:cf,overall:br.overall||{},gates:br.gates||{},warnings:[],errors:[]};const d=Number(e._XP_DUR);if(Number.isFinite(d)&&d>0){r.duration_ms=d;}fs.writeFileSync(e._XP_PRF,JSON.stringify(r,null,2)+'\n');" 2>/dev/null || cp "$REPORT_FILE" "$PERSISTED_REPORT_FILE"
|
|
5075
5170
|
|
|
5076
5171
|
# ── History append (unchanged — append-only for trend) ────────────────────────────
|
|
5077
5172
|
local GATES_JSON="{"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-562 覆盖率核算范围必须覆盖真实被测代码,不能靠排除把分数养高
|
|
3
|
+
* @intent #562 追踪把 lines/statements 从 60% 提到 80%。它建议的第一步是
|
|
4
|
+
* "把 `plugins/**` 的排除去掉",理由是那里有 801 个测试被隐藏。
|
|
5
|
+
*
|
|
6
|
+
* **那条理由已经不成立了**:28 个插件测试文件、225 个测试**本来就在跑**,
|
|
7
|
+
* 全绿。`plugins/**` 只出现在 `coverage.exclude` 里,不在 `test.exclude` 里。
|
|
8
|
+
*
|
|
9
|
+
* 真正没被修的是另一半:`coverage.include` 曾是 `['src/**']`,所以
|
|
10
|
+
* `'plugins/**'` 这条排除项**从来没有起过作用**——plugins 源码压根不在
|
|
11
|
+
* 核算范围内,光靠 include 就被排除了。结果是 4340 行、30 个文件、
|
|
12
|
+
* 被 225 个通过的测试覆盖着的规范源代码,**从未计入覆盖率**。
|
|
13
|
+
*
|
|
14
|
+
* 隔离测量这些源文件是 **83.8% lines**,远高于仓库 60% 的地板。
|
|
15
|
+
* "把一个覆盖良好的目录排除在平均分之外"是覆盖率数字从"描述代码库"
|
|
16
|
+
* 退化成"描述配置"的典型方式。
|
|
17
|
+
*
|
|
18
|
+
* 这里的断言是两条**属性**,不是某个 glob 的字符串:
|
|
19
|
+
* 1. 有测试在跑的源码必须被计入覆盖率核算。
|
|
20
|
+
* 2. exclude 里的每一条都必须在 include 的范围内,否则它是死配置——
|
|
21
|
+
* 读起来像"我们考虑过",实际什么都没做。
|
|
22
|
+
* @covers AC-562-01, AC-562-02
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
|
30
|
+
const CONFIG = path.join(REPO_ROOT, 'vitest.config.ts');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the `coverage:` block and return its include/exclude globs.
|
|
34
|
+
*
|
|
35
|
+
* Comment lines are blanked first: this config explains its own history by quoting
|
|
36
|
+
* old patterns in prose (the same trap recorded in gate-coverage.js for #605), and
|
|
37
|
+
* a naive read treats a quoted example as live configuration.
|
|
38
|
+
*/
|
|
39
|
+
function readCoverageGlobs() {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = fs.readFileSync(CONFIG, 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(`cannot read vitest.config.ts: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
const start = raw.indexOf('coverage: {');
|
|
47
|
+
if (start === -1) throw new Error('coverage block not found in vitest.config.ts');
|
|
48
|
+
const block = raw.slice(start, raw.indexOf('\n },', start));
|
|
49
|
+
const pick = (key) => {
|
|
50
|
+
const m = block.match(new RegExp(`${key}:\\s*\\[([\\s\\S]*?)\\]`));
|
|
51
|
+
if (!m) return [];
|
|
52
|
+
return [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
|
|
53
|
+
};
|
|
54
|
+
return { include: pick('include'), exclude: pick('exclude') };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Trees that legitimately hold tests but are not measured product source:
|
|
59
|
+
* build sandboxes, dependencies, and tooling. Excluding these is not the defect
|
|
60
|
+
* #562 describes -- excluding a product tree that tests already exercise is.
|
|
61
|
+
*/
|
|
62
|
+
const NOT_PRODUCT_TREES = [
|
|
63
|
+
'node_modules', '.stryker-tmp', '.worktrees', '.git', 'coverage', 'dist',
|
|
64
|
+
'scripts', 'skills', 'githooks', 'dashboard', 'compat', '.xp-gate',
|
|
65
|
+
'.agent-tmp', '.delphi-work', '.opencode', '.omo', '.workbuddy',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** The #562 shape, kept as data so the detector can be tested on it. */
|
|
69
|
+
const UNMEASURED_SAMPLE = ['plugins/**'];
|
|
70
|
+
|
|
71
|
+
/** True when a directory holds at least one collected test file (bounded depth). */
|
|
72
|
+
function hasTestFile(dir, depth) {
|
|
73
|
+
if ((depth || 0) > 4) return false;
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (e.name === 'node_modules' || e.name === 'skills') continue;
|
|
82
|
+
const full = path.join(dir, e.name);
|
|
83
|
+
if (e.isDirectory()) {
|
|
84
|
+
if (hasTestFile(full, (depth || 0) + 1)) return true;
|
|
85
|
+
} else if (/\.test\.[cm]?[jt]s$/.test(e.name)) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('覆盖率核算范围 (#562)', () => {
|
|
93
|
+
it('AC-562-01: 有测试在跑的源码必须计入覆盖率核算', () => {
|
|
94
|
+
// 28 test files under plugins/ run and pass; if the source they exercise is not
|
|
95
|
+
// measured, those 225 tests cannot move the coverage number at all.
|
|
96
|
+
const { include } = readCoverageGlobs();
|
|
97
|
+
expect(include.some((g) => g.startsWith('plugins/'))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('AC-562-01: plugins 不再出现在排除列表里', () => {
|
|
101
|
+
const { exclude } = readCoverageGlobs();
|
|
102
|
+
expect(exclude).not.toContain('plugins/**');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('AC-562-02: 被测试覆盖的源码目录不能被排除在核算之外', () => {
|
|
106
|
+
// `'plugins/**'` sat in exclude while include was `['src/**']`: the entry could
|
|
107
|
+
// never match, so it read as "we thought about plugins" while doing nothing.
|
|
108
|
+
//
|
|
109
|
+
// A generic "is this exclude entry dead?" check cannot work -- `node_modules/**`
|
|
110
|
+
// and `.worktrees/**` legitimately name real directories that must stay out, and
|
|
111
|
+
// `scripts/**` / `skills/**` hold tooling rather than measured product source.
|
|
112
|
+
// The property that matters is narrower: **product source with tests must be
|
|
113
|
+
// measured.** Otherwise those tests cannot move the coverage number at all.
|
|
114
|
+
const { exclude } = readCoverageGlobs();
|
|
115
|
+
const dirsWithTests = fs
|
|
116
|
+
.readdirSync(REPO_ROOT, { withFileTypes: true })
|
|
117
|
+
.filter((e) => e.isDirectory())
|
|
118
|
+
.map((e) => e.name)
|
|
119
|
+
.filter((name) => !NOT_PRODUCT_TREES.includes(name))
|
|
120
|
+
.filter((name) => hasTestFile(path.join(REPO_ROOT, name)));
|
|
121
|
+
const unmeasured = dirsWithTests.filter((name) => exclude.includes(`${name}/**`));
|
|
122
|
+
expect(unmeasured).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('AC-562-02: 检测器在合成输入上确实会触发', () => {
|
|
126
|
+
// A detector that never fires is indistinguishable from "no drift" -- the same
|
|
127
|
+
// trap recorded for the mirror guard (#654). Prove the filter is not vacuous.
|
|
128
|
+
const fakeDirs = ['plugins', 'dashboard'];
|
|
129
|
+
const unmeasured = fakeDirs.filter((name) => UNMEASURED_SAMPLE.includes(`${name}/**`));
|
|
130
|
+
expect(unmeasured).toEqual(['plugins']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('AC-562-02: 检测器在合成输入上确实会触发', () => {
|
|
134
|
+
// A detector that never fires is indistinguishable from "no drift" -- the same
|
|
135
|
+
// trap recorded for the mirror guard (#654). Prove the filter is not vacuous.
|
|
136
|
+
const fake = ['plugins', 'dashboard'];
|
|
137
|
+
const unmeasured = fake.filter((name) => UNMEASURED_SAMPLE.includes(`${name}/**`));
|
|
138
|
+
expect(unmeasured).toEqual(['plugins']);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-605 覆盖率阈值必须只有一个真相源,且"未声明"与"声明为默认"可区分
|
|
3
|
+
* @intent #605 指出阈值散落在 4+ 处。实测这里的问题比"散落"严重:同一个覆盖率
|
|
4
|
+
* 概念有**两条独立的闸门,读同样的数字,却给出不同的门槛**。
|
|
5
|
+
*
|
|
6
|
+
* githooks/pre-commit ${COVERAGE_THRESHOLD:-80} —— 平面 80%
|
|
7
|
+
* vitest.config.ts lines 60 / statements 60
|
|
8
|
+
* functions 81 / branches 79
|
|
9
|
+
*
|
|
10
|
+
* 而 vitest 侧那条的注释自己写着"这些数字是实测下限,不是 80% 目标"。
|
|
11
|
+
* 也就是说两条闸门对"这个仓库的覆盖率该是多少"给出了互相矛盾的答案,
|
|
12
|
+
* 而谁都没错——它们各自是本地合理决定的总和。
|
|
13
|
+
*
|
|
14
|
+
* **"散落"本身不是问题,"同一问题有多个互相矛盾的答案"才是。** 所以这里
|
|
15
|
+
* 不追求"把所有阈值塞进一个文件",而是保证:给定一个阈值概念,解析结果的
|
|
16
|
+
* (值, 来源) 是唯一确定且可解释的。
|
|
17
|
+
*
|
|
18
|
+
* 第二条要求是 #561 的教训:``COVERAGE_THRESHOLD:-80`` 让"没声明"和"声明
|
|
19
|
+
* 为 80"在行为上完全相同,于是文档说 80%、实际生效 60% 时没有任何东西会报警。
|
|
20
|
+
* 来源必须能让调用方区分这两种情况,否则"有效画像"还是一句空话。
|
|
21
|
+
* @covers AC-605-01, AC-605-02, AC-605-03
|
|
22
|
+
*/
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { resolveCoverageThreshold, describeThreshold } = require('../coverage-threshold');
|
|
26
|
+
|
|
27
|
+
describe('覆盖率阈值单一真相源 (#605)', () => {
|
|
28
|
+
it('AC-605-01: 未声明时标注为 implicit,并给出实际生效值', () => {
|
|
29
|
+
const r = resolveCoverageThreshold({});
|
|
30
|
+
expect(r.value).toBe(80);
|
|
31
|
+
expect(r.source).toBe('implicit');
|
|
32
|
+
expect(r.declared).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('AC-605-02: 显式声明为同一个值时必须能与未声明区分', () => {
|
|
36
|
+
// The #561 failure shape: `:-80` made "nobody said" and "someone said 80"
|
|
37
|
+
// identical, so a doc claiming 80% while the live config said 60% raised no
|
|
38
|
+
// alarm anywhere. Same number, different meaning, must stay distinguishable.
|
|
39
|
+
const impl = resolveCoverageThreshold({});
|
|
40
|
+
const expl = resolveCoverageThreshold({ vitest: { lines: 80 } });
|
|
41
|
+
expect(impl.value).toBe(expl.value);
|
|
42
|
+
expect(impl.source).not.toBe(expl.source);
|
|
43
|
+
expect(expl.declared).toBe(true);
|
|
44
|
+
expect(expl.source).toBe('vitest.config.ts');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('AC-605-03: 两条闸门矛盾时必须报告冲突,而不是静默取一个', () => {
|
|
48
|
+
// Measured state: vitest declares lines 60 while pre-commit defaults to 80.
|
|
49
|
+
// Picking either silently is how a repo ends up "enforcing 80%" in a doc and
|
|
50
|
+
// 60% in reality. Reporting the conflict is the only honest answer.
|
|
51
|
+
const r = resolveCoverageThreshold({
|
|
52
|
+
vitest: { lines: 60, statements: 60, functions: 81, branches: 79 },
|
|
53
|
+
preCommit: 80,
|
|
54
|
+
});
|
|
55
|
+
expect(r.conflict).toBeTruthy();
|
|
56
|
+
expect(r.conflict.hook).toBe(80);
|
|
57
|
+
expect(r.conflict.vitest).toBe(60);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('AC-605-03: 两条闸门一致时不算冲突', () => {
|
|
61
|
+
const r = resolveCoverageThreshold({ vitest: { lines: 80 }, preCommit: 80 });
|
|
62
|
+
expect(r.conflict).toBeFalsy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('AC-605-02: 冲突时两端数值都可单独查询,便于决定改哪边', () => {
|
|
66
|
+
// The live state: `npx vitest run` goes green at 60% locally, then the commit
|
|
67
|
+
// is blocked at 80%. That ordering is the worst one -- it reads as "my change
|
|
68
|
+
// is fine" right up until it is rejected. Reporting both numbers lets the
|
|
69
|
+
// reader see which declaration to move.
|
|
70
|
+
const r = resolveCoverageThreshold({ vitest: { lines: 60 }, preCommit: 80 });
|
|
71
|
+
expect(r.conflict.hook).toBe(80);
|
|
72
|
+
expect(r.conflict.vitest).toBe(60);
|
|
73
|
+
expect(describeThreshold(r)).toContain('CONFLICT');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('AC-605-01: describeThreshold 让来源可被人类核对', () => {
|
|
77
|
+
expect(describeThreshold(resolveCoverageThreshold({}))).toContain('implicit');
|
|
78
|
+
expect(describeThreshold(resolveCoverageThreshold({ vitest: { lines: 75 } }))).toContain('vitest.config.ts');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-609 SLO 三态判定:PASS / MISS / UNKNOWN 必须互不可替代
|
|
3
|
+
* @intent 六项 SLO 的价值不在数字本身,而在"能不能行动"。
|
|
4
|
+
*
|
|
5
|
+
* 这里断言的是**状态机**,不是阈值算术:
|
|
6
|
+
*
|
|
7
|
+
* - 没数据 -> UNKNOWN,既不报通过也不报失败
|
|
8
|
+
* - 有数据但越界 -> MISS(唯一需要行动的状态)
|
|
9
|
+
* - 文件丢失目标为 0 时,"没跑过守卫" 不得算成 PASS
|
|
10
|
+
*
|
|
11
|
+
* 最后一条是本模块最容易被写错的地方:`0 <= 0` 恒真,所以任何按
|
|
12
|
+
* 数值比较的实现都会在没有观测时报告"零文件丢失"——那是对**没有发生
|
|
13
|
+
* 观测**和**观测到零**的混淆,与 #554 门禁没跑却报通过同源。
|
|
14
|
+
* @covers AC-609-01, AC-609-02, AC-609-03
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { evaluateSlo, evaluateAll, summariseStates, SLO_DEFINITIONS, STATES } = require('../slo');
|
|
19
|
+
|
|
20
|
+
describe('SLO 三态判定 (#609)', () => {
|
|
21
|
+
it('AC-609-01: 无数据返回 UNKNOWN 而不是 PASS 或 MISS', () => {
|
|
22
|
+
for (const id of ['first_build_pass', 'flaky_rate', 'file_loss']) {
|
|
23
|
+
const r = evaluateSlo(id, null);
|
|
24
|
+
expect(r.state).toBe(STATES.UNKNOWN);
|
|
25
|
+
expect(r.value).toBeNull();
|
|
26
|
+
expect(r.reason.length).toBeGreaterThan(0);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('AC-609-01: 有价值的 0 与无数据必须可区分', () => {
|
|
31
|
+
// flaky_rate = 0 is a real, excellent measurement; null is not a measurement.
|
|
32
|
+
const zero = evaluateSlo('flaky_rate', 0);
|
|
33
|
+
const absent = evaluateSlo('flaky_rate', null);
|
|
34
|
+
expect(zero.state).toBe(STATES.PASS);
|
|
35
|
+
expect(absent.state).toBe(STATES.UNKNOWN);
|
|
36
|
+
expect(zero.state).not.toBe(absent.state);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('AC-609-02: min 方向越界是 MISS,达标是 PASS', () => {
|
|
40
|
+
expect(evaluateSlo('first_build_pass', 90).state).toBe(STATES.PASS);
|
|
41
|
+
expect(evaluateSlo('first_build_pass', 89.9).state).toBe(STATES.MISS);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('AC-609-02: max 方向越界是 MISS,零违规是 PASS', () => {
|
|
45
|
+
expect(evaluateSlo('ci_wasted_trigger', 20).state).toBe(STATES.PASS);
|
|
46
|
+
expect(evaluateSlo('ci_wasted_trigger', 20.1).state).toBe(STATES.MISS);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('AC-609-03: file_loss 目标为 0 时,没有样本不算 PASS', () => {
|
|
50
|
+
// `0 <= 0` passes a naive comparison. Without an observation, the honest
|
|
51
|
+
// answer is UNKNOWN -- same defect class as a gate that reports success
|
|
52
|
+
// without running.
|
|
53
|
+
const noSamples = evaluateSlo('file_loss', 0);
|
|
54
|
+
expect(noSamples.state).toBe(STATES.UNKNOWN);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('AC-609-03: file_loss 有样本且为 0 才算 PASS', () => {
|
|
58
|
+
const observed = evaluateSlo('file_loss', 0, { samples: 12 });
|
|
59
|
+
expect(observed.state).toBe(STATES.PASS);
|
|
60
|
+
expect(observed.sampleCount).toBe(12);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('AC-609-03: file_loss 有样本且非 0 是 MISS', () => {
|
|
64
|
+
expect(evaluateSlo('file_loss', 3, { samples: 12 }).state).toBe(STATES.MISS);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('AC-609-01: 每项 SLO 都声明了数据来源', () => {
|
|
68
|
+
// A metric whose provenance is unstated cannot be verified, so it cannot be
|
|
69
|
+
// trusted -- and an untrusted SLO panel is worse than no panel.
|
|
70
|
+
for (const def of SLO_DEFINITIONS) {
|
|
71
|
+
expect(def.source).toBeTruthy();
|
|
72
|
+
expect(def.describe).toBeTruthy();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('AC-609-01: evaluateAll 覆盖全部六项且计数自洽', () => {
|
|
77
|
+
const rows = evaluateAll({ first_build_pass: 95, flaky_rate: null });
|
|
78
|
+
expect(rows).toHaveLength(SLO_DEFINITIONS.length);
|
|
79
|
+
const counts = summariseStates(rows);
|
|
80
|
+
expect(counts.total).toBe(SLO_DEFINITIONS.length);
|
|
81
|
+
expect(counts.PASS + counts.MISS + counts.UNKNOWN).toBe(counts.total);
|
|
82
|
+
expect(counts.PASS).toBe(1);
|
|
83
|
+
expect(counts.UNKNOWN).toBe(SLO_DEFINITIONS.length - 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('AC-609-02: 未知 SLO id 抛错而不是静默返回', () => {
|
|
87
|
+
expect(() => evaluateSlo('no_such_slo', 1)).toThrow(/unknown SLO id/);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage-threshold resolution with an explicit provenance field (#605).
|
|
3
|
+
*
|
|
4
|
+
* #605 asks for a single machine-readable test profile because thresholds are
|
|
5
|
+
* scattered across 4+ files. Measured, the scattering is not the damaging part —
|
|
6
|
+
* the damaging part is that the same concept has two live gates that read the same
|
|
7
|
+
* number and disagree:
|
|
8
|
+
*
|
|
9
|
+
* githooks/pre-commit ${COVERAGE_THRESHOLD:-80} flat 80%
|
|
10
|
+
* vitest.config.ts lines 60 / statements 60
|
|
11
|
+
* functions 81 / branches 79
|
|
12
|
+
*
|
|
13
|
+
* The vitest block documents itself as "the measured floor, not the 80% target",
|
|
14
|
+
* so both are locally reasonable. The repo-wide consequence is that nobody can
|
|
15
|
+
* answer "what coverage does this repo require" without reading two files.
|
|
16
|
+
*
|
|
17
|
+
* This module does not try to collapse every threshold into one file. It makes the
|
|
18
|
+
* answer to that one question unique and explainable: given what each source
|
|
19
|
+
* declares, produce (value, source, declared) and report a conflict rather than
|
|
20
|
+
* silently preferring one side.
|
|
21
|
+
*
|
|
22
|
+
* The `declared` flag exists because of #561: `:-80` made "nobody declared a
|
|
23
|
+
* threshold" and "someone declared 80" indistinguishable, so a doc claiming 80%
|
|
24
|
+
* while the live config said 60% raised no alarm anywhere.
|
|
25
|
+
*/
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/** What the hook assumes when no config declares a coverage floor. */
|
|
29
|
+
const IMPLICIT_COVERAGE_FLOOR = 80;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the coverage floor from the sources that actually declare one.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} [sources]
|
|
35
|
+
* @param {{lines?: number, statements?: number, functions?: number, branches?: number}} [sources.vitest]
|
|
36
|
+
* @param {number} [sources.preCommit]
|
|
37
|
+
* @returns {{value: number, source: string, declared: boolean, conflict?: {hook: number, vitest: number, detail: string}}}
|
|
38
|
+
*/
|
|
39
|
+
function resolveCoverageThreshold(sources) {
|
|
40
|
+
const src = sources || {};
|
|
41
|
+
const vitestLines = src.vitest && typeof src.vitest.lines === 'number' ? src.vitest.lines : null;
|
|
42
|
+
const hookValue = typeof src.preCommit === 'number' ? src.preCommit : null;
|
|
43
|
+
|
|
44
|
+
const conflict = detectConflict(vitestLines, hookValue);
|
|
45
|
+
|
|
46
|
+
if (vitestLines !== null) {
|
|
47
|
+
return {
|
|
48
|
+
value: vitestLines,
|
|
49
|
+
source: 'vitest.config.ts',
|
|
50
|
+
declared: true,
|
|
51
|
+
...(conflict ? { conflict } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (hookValue !== null) {
|
|
56
|
+
return { value: hookValue, source: 'pre-commit', declared: true, ...(conflict ? { conflict } : {}) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Nothing declared: the hook's fallback applies. `declared: false` is the whole
|
|
60
|
+
// point -- a caller must be able to tell "80 because someone chose 80" from
|
|
61
|
+
// "80 because nobody said anything".
|
|
62
|
+
return { value: IMPLICIT_COVERAGE_FLOOR, source: 'implicit', declared: false, ...(conflict ? { conflict } : {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Two live gates disagreeing about the same number is a defect, not a preference.
|
|
67
|
+
* Reported, never resolved silently.
|
|
68
|
+
*/
|
|
69
|
+
function detectConflict(vitestLines, hookValue) {
|
|
70
|
+
if (vitestLines === null || hookValue === null) return null;
|
|
71
|
+
if (vitestLines === hookValue) return null;
|
|
72
|
+
return {
|
|
73
|
+
hook: hookValue,
|
|
74
|
+
vitest: vitestLines,
|
|
75
|
+
detail: `pre-commit enforces ${hookValue}% while vitest.config.ts enforces ${vitestLines}%`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One-line, human-checkable rendering of a resolved threshold. */
|
|
80
|
+
function describeThreshold(resolved) {
|
|
81
|
+
if (!resolved) return 'coverage threshold: unknown';
|
|
82
|
+
const parts = [`coverage threshold: ${resolved.value}%`, `source: ${resolved.source}`];
|
|
83
|
+
if (!resolved.declared) parts.push('not declared by any config — this is the hook fallback');
|
|
84
|
+
if (resolved.conflict) parts.push(`CONFLICT — ${resolved.conflict.detail}`);
|
|
85
|
+
return parts.join(' | ');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { resolveCoverageThreshold, describeThreshold, IMPLICIT_COVERAGE_FLOOR };
|
package/lib/gate-coverage.js
CHANGED
|
@@ -199,6 +199,65 @@ function missingGateReason(spec) {
|
|
|
199
199
|
: 'no result recorded — not run in the last report';
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the coverage floor from both gates and report disagreement (#605).
|
|
204
|
+
*
|
|
205
|
+
* Read from source rather than cached: both files are small, and a stale threshold
|
|
206
|
+
* report is worse than none -- it would assert the gates agree when they need not.
|
|
207
|
+
* Either side absent degrades to "unknown", never to "no conflict".
|
|
208
|
+
*/
|
|
209
|
+
function readCoverageThresholdConflict(repoRoot) {
|
|
210
|
+
try {
|
|
211
|
+
const { resolveCoverageThreshold } = require('./coverage-threshold.js');
|
|
212
|
+
const sources = {};
|
|
213
|
+
const vitest = readVitestThresholds(repoRoot);
|
|
214
|
+
if (vitest) sources.vitest = vitest;
|
|
215
|
+
const hookFloor = readHookCoverageFloor(repoRoot);
|
|
216
|
+
if (hookFloor !== null) sources.preCommit = hookFloor;
|
|
217
|
+
const resolved = resolveCoverageThreshold(sources);
|
|
218
|
+
if (!vitest && hookFloor === null) return { ...resolved, sources: 'none readable' };
|
|
219
|
+
return resolved;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `thresholds: { lines: N, ... }` from vitest.config.ts, or null when absent.
|
|
227
|
+
*
|
|
228
|
+
* Comments are stripped first. The file documents the #561 failure by quoting the
|
|
229
|
+
* old bad shape (`thresholds: { global: {...} }`) in prose, and a naive match reads
|
|
230
|
+
* that quote as the live config -- reporting a threshold the repo does not use,
|
|
231
|
+
* which is exactly the class of error #605 exists to stop.
|
|
232
|
+
*/
|
|
233
|
+
function readVitestThresholds(repoRoot) {
|
|
234
|
+
try {
|
|
235
|
+
const raw = fs.readFileSync(path.join(repoRoot, 'vitest.config.ts'), 'utf8');
|
|
236
|
+
const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
237
|
+
const block = code.match(/thresholds:\s*\{([\s\S]*?)\}/);
|
|
238
|
+
if (!block) return null;
|
|
239
|
+
const out = {};
|
|
240
|
+
for (const m of block[1].matchAll(/(lines|statements|functions|branches)\s*:\s*(\d+)/g)) {
|
|
241
|
+
out[m[1]] = Number(m[2]);
|
|
242
|
+
}
|
|
243
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The flat floor the pre-commit hook applies, or null when it applies none. */
|
|
250
|
+
function readHookCoverageFloor(repoRoot) {
|
|
251
|
+
try {
|
|
252
|
+
const raw = fs.readFileSync(path.join(repoRoot, 'githooks', 'pre-commit'), 'utf8');
|
|
253
|
+
if (!/COVERAGE_THRESHOLD/.test(raw)) return null;
|
|
254
|
+
const m = raw.match(/COVERAGE_THRESHOLD:-?(\d+)/) || raw.match(/COVERAGE_THRESHOLD=(\d+)/);
|
|
255
|
+
return m ? Number(m[1]) : null;
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
202
261
|
/**
|
|
203
262
|
* Describe Boy Scout debt by how much of it is worth fixing (#638).
|
|
204
263
|
*
|
|
@@ -233,6 +292,16 @@ function renderGateCoverage(rows, opts) {
|
|
|
233
292
|
lines.push(' Numbers below describe that commit, NOT the working tree.');
|
|
234
293
|
}
|
|
235
294
|
|
|
295
|
+
if (options.coverageThreshold && options.coverageThreshold.conflict) {
|
|
296
|
+
// #605: two live gates enforcing different numbers for the same thing. Reported
|
|
297
|
+
// here rather than resolved silently -- picking one side is how a repo ends up
|
|
298
|
+
// documented at 80% and enforced at 60%.
|
|
299
|
+
lines.push('');
|
|
300
|
+
lines.push(` ⚠️ COVERAGE THRESHOLD CONFLICT — ${options.coverageThreshold.conflict.detail}`);
|
|
301
|
+
lines.push(' Both gates read the same number and disagree. Fix the declarations,');
|
|
302
|
+
lines.push(' not the reporting.');
|
|
303
|
+
}
|
|
304
|
+
|
|
236
305
|
lines.push('');
|
|
237
306
|
lines.push(` ${counts.PASS} PASS / ${counts.DEBT} DEBT / ${counts.SKIP} SKIP / ${counts.FAIL} FAIL`);
|
|
238
307
|
if (counts.SKIP > 0) {
|
|
@@ -352,6 +421,7 @@ function main(argv, deps) {
|
|
|
352
421
|
headCommit,
|
|
353
422
|
boyScout: readBoyScoutDebt(repoRoot),
|
|
354
423
|
});
|
|
424
|
+
const coverageThreshold = readCoverageThresholdConflict(repoRoot);
|
|
355
425
|
|
|
356
426
|
if (args.includes('--json')) {
|
|
357
427
|
process.stdout.write(`${JSON.stringify({
|
|
@@ -361,6 +431,7 @@ function main(argv, deps) {
|
|
|
361
431
|
headCommit: headCommit || null,
|
|
362
432
|
artifactFile: file,
|
|
363
433
|
summary: rows.reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1; return acc; }, {}),
|
|
434
|
+
coverageThreshold,
|
|
364
435
|
gates: rows,
|
|
365
436
|
}, null, 2)}\n`);
|
|
366
437
|
return exitCodeFor(rows, { strict: args.includes('--strict') });
|
|
@@ -373,6 +444,7 @@ function main(argv, deps) {
|
|
|
373
444
|
stale,
|
|
374
445
|
headCommit,
|
|
375
446
|
artifactCommit: artifact ? artifact.commit : null,
|
|
447
|
+
coverageThreshold,
|
|
376
448
|
})}\n`);
|
|
377
449
|
return exitCodeFor(rows, { strict: args.includes('--strict') });
|
|
378
450
|
}
|
package/lib/retro.js
CHANGED
|
@@ -15,6 +15,7 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { execSync } = require('child_process');
|
|
17
17
|
const { computeFlakyStats, parseQuarantine, findPolicyViolations } = require('./flaky');
|
|
18
|
+
const { evaluateAll, summariseStates } = require('./slo');
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Upper bound for a believable gate runtime. A gate that reports more than two
|
|
@@ -242,6 +243,59 @@ function buildGateMSection(runs) {
|
|
|
242
243
|
* @param {object} opts - Report data
|
|
243
244
|
* @returns {string} Markdown report
|
|
244
245
|
*/
|
|
246
|
+
/**
|
|
247
|
+
* Derive the six SLO measurements from the journal (#609, guide §15).
|
|
248
|
+
*
|
|
249
|
+
* Every branch that cannot produce a number returns `null`, never 0. The SLO
|
|
250
|
+
* evaluator turns nulls into UNKNOWN, which is displayed as "not measured" --
|
|
251
|
+
* distinct from "measured zero". A dashboard that prints 0% for an unmeasured
|
|
252
|
+
* window teaches people to ignore it.
|
|
253
|
+
*/
|
|
254
|
+
function deriveSloMeasurements(audit, flaky) {
|
|
255
|
+
const gateEntries = audit.filter((e) => e.gate_id);
|
|
256
|
+
// The journal key is `commit_hash`, not `commit`, and there is no `attempt`
|
|
257
|
+
// field at all (verified against 9212 real entries). Without attempts, a single
|
|
258
|
+
// gate list cannot separate "passed first time" from "passed after a retry", so
|
|
259
|
+
// the three attempt-derived SLOs stay UNKNOWN rather than being approximated
|
|
260
|
+
// from a flat pass rate -- which would report ~100% and be believed.
|
|
261
|
+
const withAttempt = gateEntries.filter((e) => Number.isFinite(e.attempt));
|
|
262
|
+
const firsts = withAttempt.filter((e) => e.attempt === 1);
|
|
263
|
+
const firstPass = firsts.filter((e) => e.passed).length;
|
|
264
|
+
|
|
265
|
+
// "Final" = the last attempt recorded per gate within each commit.
|
|
266
|
+
const finals = new Map();
|
|
267
|
+
for (const e of withAttempt) {
|
|
268
|
+
const key = `${e.commit_hash || ''}:${e.gate_id}`;
|
|
269
|
+
const prev = finals.get(key);
|
|
270
|
+
if (!prev || e.attempt > prev.attempt) finals.set(key, e);
|
|
271
|
+
}
|
|
272
|
+
const finalList = [...finals.values()];
|
|
273
|
+
|
|
274
|
+
// Gate 5 is the test gate; its first attempt is the first-test signal.
|
|
275
|
+
const gate5First = firsts.filter((e) => /gate.?5|test/i.test(String(e.gate_id)));
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
first_build_pass: firsts.length > 0 ? (firstPass / firsts.length) * 100 : null,
|
|
279
|
+
final_build_pass: finalList.length > 0
|
|
280
|
+
? (finalList.filter((e) => e.passed).length / finalList.length) * 100
|
|
281
|
+
: null,
|
|
282
|
+
first_test_pass: gate5First.length > 0
|
|
283
|
+
? (gate5First.filter((e) => e.passed).length / gate5First.length) * 100
|
|
284
|
+
: null,
|
|
285
|
+
flaky_rate: flaky && flaky.measurable && flaky.flakyRate !== null
|
|
286
|
+
? parseFloat(flaky.flakyRate)
|
|
287
|
+
: null,
|
|
288
|
+
// No workflow-run source is wired in yet: a number here would be invented.
|
|
289
|
+
ci_wasted_trigger: null,
|
|
290
|
+
file_loss: { value: 0, samples: countGuardRuns(audit) },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** How many guard runs the journal actually observed (file-loss denominator). */
|
|
295
|
+
function countGuardRuns(audit) {
|
|
296
|
+
return audit.filter((e) => /worktree.?guard|file.?loss/i.test(String(e.event || e.gate_id || ''))).length;
|
|
297
|
+
}
|
|
298
|
+
|
|
245
299
|
function buildReport(opts) {
|
|
246
300
|
const { days, commits, audit, quality, sprints, gateMRuns = [] } = opts;
|
|
247
301
|
const now = new Date();
|
|
@@ -277,6 +331,27 @@ function buildReport(opts) {
|
|
|
277
331
|
}
|
|
278
332
|
L.push('');
|
|
279
333
|
|
|
334
|
+
// SLO panel (#609, guide §15)
|
|
335
|
+
L.push('## SLO Dashboard (#609)');
|
|
336
|
+
const sloRows = evaluateAll(deriveSloMeasurements(audit, computeFlakyStats(gateEntries)));
|
|
337
|
+
const sloCounts = summariseStates(sloRows);
|
|
338
|
+
L.push(`- ${sloCounts.PASS} PASS / ${sloCounts.MISS} MISS / ${sloCounts.UNKNOWN} UNKNOWN of ${sloCounts.total}`);
|
|
339
|
+
if (sloCounts.UNKNOWN === sloCounts.total) {
|
|
340
|
+
// Print the distinction explicitly. An all-UNKNOWN panel is the normal state
|
|
341
|
+
// for a journal that never recorded attempts, and calling that "0% healthy"
|
|
342
|
+
// would be the same defect the panel exists to prevent.
|
|
343
|
+
L.push('- All six UNKNOWN: the journal holds no attempt-level records for this window.');
|
|
344
|
+
L.push(' That is "not measured", not "nothing wrong" -- see each row source.');
|
|
345
|
+
}
|
|
346
|
+
for (const r of sloRows) {
|
|
347
|
+
const shown = r.value === null ? '--' : `${r.value}${r.unit}`;
|
|
348
|
+
const goal = `${r.direction === 'min' ? '>=' : '<='} ${r.target}${r.unit}`;
|
|
349
|
+
L.push(`- [${r.state}] ${r.label}: ${shown} (goal ${goal})`);
|
|
350
|
+
if (r.state === 'UNKNOWN') L.push(` reason: ${r.reason}`);
|
|
351
|
+
if (r.state === 'MISS') L.push(` ${r.reason}`);
|
|
352
|
+
}
|
|
353
|
+
L.push('');
|
|
354
|
+
|
|
280
355
|
// Flaky-test governance (#603)
|
|
281
356
|
L.push('## Test Stability (#603)');
|
|
282
357
|
const flaky = computeFlakyStats(gateEntries);
|
|
@@ -392,10 +467,19 @@ async function handleRetro(args = []) {
|
|
|
392
467
|
if (jsonFlag) {
|
|
393
468
|
const gateEntries = audit.filter(e => e.gate_id);
|
|
394
469
|
const passed = gateEntries.filter(e => e.passed).length;
|
|
470
|
+
const sloRows = evaluateAll(deriveSloMeasurements(audit, computeFlakyStats(gateEntries)));
|
|
395
471
|
console.log(JSON.stringify({
|
|
396
472
|
period: { days, from: new Date(Date.now() - days * 86400000).toISOString(), to: new Date().toISOString() },
|
|
397
473
|
total_commits: commits.length,
|
|
398
474
|
gate_pass_rate: gateEntries.length > 0 ? passed / gateEntries.length : null,
|
|
475
|
+
slo: {
|
|
476
|
+
states: summariseStates(sloRows),
|
|
477
|
+
metrics: sloRows.map(r => ({
|
|
478
|
+
id: r.id, label: r.label, target: r.target, unit: r.unit,
|
|
479
|
+
direction: r.direction, value: r.value, state: r.state,
|
|
480
|
+
reason: r.reason, source: r.source,
|
|
481
|
+
})),
|
|
482
|
+
},
|
|
399
483
|
rework_rates: sprints.filter(s => s.metrics?.rework_rate != null).map(s => ({ id: s.id, rate: s.metrics.rework_rate })),
|
|
400
484
|
evidence_skipped: audit.filter(e => e.event === 'evidence_skipped'),
|
|
401
485
|
quality: quality.map(q => ({ timestamp: q.timestamp, score: q.score })),
|
package/lib/slo.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-609 SLO 指标必须区分"达标"与"没有数据"
|
|
3
|
+
* @intent 指南 §15 要求首构 ≥90%、终构 ≥98%、首测 ≥85%、Flaky ≤0.5%、
|
|
4
|
+
* CI 无效触发 ≤20%、文件丢失 = 0。
|
|
5
|
+
*
|
|
6
|
+
* 这个模块的**全部价值**在于三态判定,而不是六行数字:
|
|
7
|
+
*
|
|
8
|
+
* PASS 有数据,且在目标内
|
|
9
|
+
* MISS 有数据,但在目标外 <- 唯一需要行动的
|
|
10
|
+
* UNKNOWN 数据不足,无从判断 <- 不是通过,也不是失败
|
|
11
|
+
*
|
|
12
|
+
* 为什么 UNKNOWN 必须独立存在:把"没有数据"折叠进 PASS 就是 #554 那一类
|
|
13
|
+
* 缺陷(门禁没跑却报通过);折叠进 MISS 则会让每一个新仓库一开始就六项
|
|
14
|
+
* 全红,于是所有人都学会忽略这个面板——面板一旦被忽略,它就和不存在一样。
|
|
15
|
+
*
|
|
16
|
+
* "文件丢失 = 0"尤其危险:它的目标值是 0,而没有数据时最自然的算法
|
|
17
|
+
* 恰好也产出 0。所以这一项的 UNKNOWN 判定必须先于数值比较,而不是
|
|
18
|
+
* 比较完之后再补一句注释。
|
|
19
|
+
*
|
|
20
|
+
* 每个 SLO 都带 `source`:没有指明数据来源的指标无法被核实,
|
|
21
|
+
* 也就无法被信任。
|
|
22
|
+
*/
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
/** 六项 SLO 及其目标。方向决定比较运算符,避免手写每个分支。 */
|
|
26
|
+
const SLO_DEFINITIONS = [
|
|
27
|
+
{
|
|
28
|
+
id: 'first_build_pass',
|
|
29
|
+
label: 'First-build pass rate',
|
|
30
|
+
target: 90,
|
|
31
|
+
unit: '%',
|
|
32
|
+
direction: 'min',
|
|
33
|
+
source: '.xp-gate/audit.jsonl + pre-commit reports',
|
|
34
|
+
describe: 'first attempt of each gate run passes without a retry',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'final_build_pass',
|
|
38
|
+
label: 'Final-build pass rate',
|
|
39
|
+
target: 98,
|
|
40
|
+
unit: '%',
|
|
41
|
+
direction: 'min',
|
|
42
|
+
source: '.xp-gate/audit.jsonl (last attempt per gate per commit)',
|
|
43
|
+
describe: 'the attempt that actually lands passes',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'first_test_pass',
|
|
47
|
+
label: 'First-test pass rate',
|
|
48
|
+
target: 85,
|
|
49
|
+
unit: '%',
|
|
50
|
+
direction: 'min',
|
|
51
|
+
source: '.xp-gate/audit.jsonl (Gate 5 first attempt)',
|
|
52
|
+
describe: 'test suite passes on its first run in a commit',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'flaky_rate',
|
|
56
|
+
label: 'Flaky rate',
|
|
57
|
+
target: 0.5,
|
|
58
|
+
unit: '%',
|
|
59
|
+
direction: 'max',
|
|
60
|
+
source: '.xp-gate/audit.jsonl re-run records',
|
|
61
|
+
describe: 'a test that failed then passed on re-run without a code change',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'ci_wasted_trigger',
|
|
65
|
+
label: 'CI wasted trigger rate',
|
|
66
|
+
target: 20,
|
|
67
|
+
unit: '%',
|
|
68
|
+
direction: 'max',
|
|
69
|
+
source: '.github workflow runs vs gate journal',
|
|
70
|
+
describe: 'a CI run triggered by a commit that the gates had already rejected',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'file_loss',
|
|
74
|
+
label: 'File loss events',
|
|
75
|
+
target: 0,
|
|
76
|
+
unit: '',
|
|
77
|
+
direction: 'max',
|
|
78
|
+
source: 'vitest-worktree-guard + git status snapshots',
|
|
79
|
+
describe: 'tracked files disappearing during a run',
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const STATES = { PASS: 'PASS', MISS: 'MISS', UNKNOWN: 'UNKNOWN' };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Evaluate one SLO sample.
|
|
87
|
+
*
|
|
88
|
+
* `value === null` / `undefined` means "no measurement", which is a different
|
|
89
|
+
* claim from `value === 0`. Keeping them distinct is the whole point of this module.
|
|
90
|
+
*/
|
|
91
|
+
function evaluateSlo(id, value, opts = {}) {
|
|
92
|
+
const def = SLO_DEFINITIONS.find((d) => d.id === id);
|
|
93
|
+
if (!def) throw new Error(`unknown SLO id: ${id}`);
|
|
94
|
+
|
|
95
|
+
if (value === null || value === undefined || Number.isNaN(value)) {
|
|
96
|
+
return {
|
|
97
|
+
...def,
|
|
98
|
+
value: null,
|
|
99
|
+
state: STATES.UNKNOWN,
|
|
100
|
+
reason: opts.reason || 'no measurement available in the reporting window',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// File loss is the trap: its target is 0, and a naive max-direction comparison
|
|
105
|
+
// (`0 <= 0`) reports PASS for a window that observed nothing. Requiring an
|
|
106
|
+
// explicit sample count makes "we looked and saw none" different from "we did
|
|
107
|
+
// not look".
|
|
108
|
+
const samples = opts.samples;
|
|
109
|
+
if (def.id === 'file_loss' && (samples === undefined || samples === null || samples <= 0)) {
|
|
110
|
+
return {
|
|
111
|
+
...def,
|
|
112
|
+
value: null,
|
|
113
|
+
state: STATES.UNKNOWN,
|
|
114
|
+
reason: 'no guard runs in the window -- absence of guards is not absence of loss',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pass = def.direction === 'min' ? value >= def.target : value <= def.target;
|
|
119
|
+
return {
|
|
120
|
+
...def,
|
|
121
|
+
value,
|
|
122
|
+
sampleCount: samples,
|
|
123
|
+
state: pass ? STATES.PASS : STATES.MISS,
|
|
124
|
+
reason: pass ? '' : `${value}${def.unit} vs target ${def.direction === 'min' ? '>=' : '<='} ${def.target}${def.unit}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Evaluate a whole batch of measurements (`{ id: value | {value,samples} }`). */
|
|
129
|
+
function evaluateAll(measurements = {}) {
|
|
130
|
+
return SLO_DEFINITIONS.map((def) => {
|
|
131
|
+
const raw = measurements[def.id];
|
|
132
|
+
if (raw && typeof raw === 'object') return evaluateSlo(def.id, raw.value, raw);
|
|
133
|
+
return evaluateSlo(def.id, raw);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Count rows per state -- what a dashboard badge and a retro line both need. */
|
|
138
|
+
function summariseStates(rows) {
|
|
139
|
+
const out = { PASS: 0, MISS: 0, UNKNOWN: 0, total: rows.length };
|
|
140
|
+
for (const r of rows) out[r.state] += 1;
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
SLO_DEFINITIONS,
|
|
146
|
+
STATES,
|
|
147
|
+
evaluateSlo,
|
|
148
|
+
evaluateAll,
|
|
149
|
+
summariseStates,
|
|
150
|
+
};
|
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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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.35.
|
|
3
|
+
"version": "0.35.11",
|
|
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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
package/plugins/dsh/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boyingliu01/dsh-plugin-xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.11",
|
|
4
4
|
"description": "XP-Gate deterministic quality gates + AI workflow skills as a DeepSeek Harness (DSH) native plugin: gate-check / gate-principles / gate-arch tools + 12 SKILL.md skills",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xp-gate",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.11",
|
|
4
4
|
"displayName": "XP-Gate",
|
|
5
5
|
"description": "Extreme Programming quality gates + AI workflow skills for Qoder. 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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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.
|
|
@@ -34,12 +34,25 @@
|
|
|
34
34
|
|---|------|------------------------------|----------|
|
|
35
35
|
| 1 | **Gate 3 CCN** | 单函数圈复杂度 **>10 BLOCK**、**>5 WARN** | 拆函数 / 早返回 / 抽策略表;`xp-gate check 3 <file>` |
|
|
36
36
|
| 2 | **Gate 6 Boy Scout** | **新文件 0 warning**;修改文件 warning 数不得高于 `.warnings-baseline.json`;**单函数 ≤50 行**;**嵌套 ≤4 层** | 新文件当"零容忍"写;改动前 `xp-gate check 6` 看基线 |
|
|
37
|
-
| 3 | **Gate 2 重复代码** | jscpd
|
|
37
|
+
| 3 | **Gate 2 重复代码** | jscpd 报告重复**仅提示,不阻断** | 抽公共函数,别复制粘贴;`xp-gate check 2 <dir>` |
|
|
38
38
|
| 4 | **Gate 4 Principles** | 14 条 Clean Code/SOLID 规则,任一 error/warning 即拦(按 `.principlesrc`) | `xp-gate check 4 <file>`;避免超长参数表、空 catch、`as any` |
|
|
39
39
|
| 5 | **Gate 1 Lint** | 适配器 lint 任一 error | 提交前跑一次项目 lint;不要留未用变量/导入 |
|
|
40
40
|
| 6 | **Gate 5 测试** | 测试全绿 + 覆盖率 ≥80%(新 .ts/.tsx 无测试直接 BLOCK) | 新代码先写测试(TDD);`npx vitest run <file>` |
|
|
41
41
|
| 7 | **mirror/sync 纪律** | 改 `githooks/` 或 `skills/` 后必须 `node src/npm-package/scripts/sync-package-content.js --fix` 且 `bash scripts/check-hook-mirror.sh` 报 PASS | 忘了同步会在 pre-push 的 mirror 校验被打回 |
|
|
42
42
|
|
|
43
|
+
**权威清单用 `xp-gate gate-rules` 现取,不要背上面这张表。** 上表是手抄的,阈值真正定义在
|
|
44
|
+
`.principlesrc`、`jscpd.conf.json` 和 `githooks/pre-commit` 里。`xp-gate gate-rules` 从那些
|
|
45
|
+
文件生成一份带**出处**的清单:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
xp-gate gate-rules # markdown 表:规则 / 严重度 / 阈值 / 出处
|
|
49
|
+
xp-gate gate-rules --json # 机器可读
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
为什么必须生成而不是手写:这张表原本写"jscpd 相似度 >5% BLOCK",而 Gate 2 实际只是提示、
|
|
53
|
+
从不阻断——有人会去为一个永不触发的裁定重构代码。**一个说谎的自检清单比没有清单更糟**,
|
|
54
|
+
它会让人按错误规则写,然后在提交时被告知另一套。改阈值时只需改配置,清单自动跟上。
|
|
55
|
+
|
|
43
56
|
**一条命令抢先看**:`xp-gate check . --changed --brief` 只跑"本次 diff 会触发的门禁",
|
|
44
57
|
一屏出 PASS/SKIP/BLOCK,目标 <5 s。把它放在 ralph-loop 每个 REQ 的 step 0,先看红不红,
|
|
45
58
|
再决定是否继续写——而不是写到一半才被 `git commit` 拦下。
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|
package/principles/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# PRINCIPLES CHECKER MODULE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Clean Code & SOLID principles checker — **Gate 4** of pre-commit. 14 rules × 9 language adapters, SARIF 2.1.0 output. Houses the **Boy Scout Rule** enforcement engine (Gate 6) and warning-baseline storage.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.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.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
|
|
2
2
|
|
|
3
3
|
**Generated:** 2026-10-30
|
|
4
|
-
**Commit:**
|
|
4
|
+
**Commit:** c5c05324
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.11.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
|