@boyingliu01/xp-gate 0.31.1 → 0.31.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/adapter-common.sh +41 -3
- package/adapters/gate-3.sh +8 -1
- package/adapters/gate-8.sh +67 -6
- package/adapters/go.sh +18 -1
- package/adapters/iac.sh +18 -1
- package/adapters/powershell.sh +37 -1
- package/adapters/python.sh +18 -4
- package/gate-3.sh +10 -1
- package/gate-8.sh +67 -6
- package/hooks/adapter-common.sh +41 -3
- package/hooks/gate-3.sh +10 -1
- package/hooks/gate-8.sh +67 -6
- package/hooks/lib/coverage-total.sh +20 -5
- package/hooks/lib/gate-5c-alignment.sh +145 -0
- package/hooks/lib/jscpd-run.sh +40 -1
- package/hooks/lib/native-path.sh +106 -0
- package/hooks/lib/prepush-score.py +143 -0
- package/hooks/lib/sprint-gate-report.sh +23 -0
- package/hooks/lib/toolchain-path.sh +370 -0
- package/hooks/lib/validate-code-walkthrough.cjs +224 -23
- package/hooks/pre-commit +371 -163
- package/hooks/pre-push +192 -74
- package/hooks/sprint-gate.sh +14 -3
- package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
- package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
- package/lib/__tests__/doctor-delphi.test.js +46 -0
- package/lib/__tests__/doctor-exec-retry.test.js +20 -10
- package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
- package/lib/__tests__/doctor.test.js +7 -1
- package/lib/__tests__/hook-health-path.test.js +9 -1
- package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
- package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
- package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
- package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
- package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
- package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
- package/lib/arch.js +261 -44
- package/lib/check-alignment-runner.cjs +23 -0
- package/lib/check.js +4 -1
- package/lib/doctor.js +194 -49
- package/lib/gate-runner.js +265 -58
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +228 -6
- package/lib/shared-paths.js +53 -19
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
- package/plugins/dsh/src/delphi-config.ts +151 -9
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
- package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
- package/principles/adapters/java.ts +127 -11
- package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
- package/principles/rules/solid/lsp.ts +348 -23
- package/scripts/__tests__/sync-package-content.test.js +32 -1
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +50 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/sprint-gate.sh +14 -3
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Gate 5c: spec/test alignment (#585).
|
|
3
|
+
#
|
|
4
|
+
# Extracted from pre-commit so the contract is unit-testable without executing the
|
|
5
|
+
# hook, the same way lib/coverage-total.sh and lib/sprint-gate-report.sh are.
|
|
6
|
+
#
|
|
7
|
+
# The defect this file exists for: the whole check sat inside
|
|
8
|
+
# if [ -f "$REQ_IDS_FILE" ] && command -v jq >/dev/null 2>&1; then
|
|
9
|
+
# so in the normal state — req-ids.json is produced by `xp-gate check-alignment`
|
|
10
|
+
# into .sprint-state/, which is gitignored and therefore absent in every fresh
|
|
11
|
+
# clone — the gate printed NOTHING. Not PASS, not FAIL, not SKIP. The quality
|
|
12
|
+
# report had no slot for it either. A repository's semantic alignment had never
|
|
13
|
+
# been checked once and every report was green.
|
|
14
|
+
#
|
|
15
|
+
# A gate that answers "not checked" by staying silent is the same defect class as
|
|
16
|
+
# #449, #499, #570, #575 and #594: silence read as success.
|
|
17
|
+
|
|
18
|
+
# Test-file conventions, per language. A gate that only knows JS/TS reports
|
|
19
|
+
# "0 annotated, 0 uncovered" on a PowerShell, Python, Go, Java or Kotlin
|
|
20
|
+
# repository — a vacuous green (#585).
|
|
21
|
+
#
|
|
22
|
+
# Pester: *.Tests.ps1, *.test.ps1 (Pester's documented naming)
|
|
23
|
+
# pytest: test_*.py, *_test.py
|
|
24
|
+
# Go: *_test.go
|
|
25
|
+
# Java: *Test.java, *Tests.java
|
|
26
|
+
# Kotlin: *Test.kt, *Tests.kt
|
|
27
|
+
# JS/TS: *.test.*, *.spec.*
|
|
28
|
+
GATE5C_TEST_FILE_PATTERN='(\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs)$)|(\.([Tt]ests?)\.ps1$)|(^(test_|.*/test_)[^/]*\.py$)|(^([^/]*_test)\.py$)|(_test\.go$)|([Tt]ests?\.java$)|([Tt]ests?\.kt$)'
|
|
29
|
+
|
|
30
|
+
# Is this changed-file list carrying anything that looks like a test file?
|
|
31
|
+
#
|
|
32
|
+
# Uses the same CHANGED_FILES variable the hook holds at that point in its flow.
|
|
33
|
+
gate_5c_test_files() {
|
|
34
|
+
printf '%s\n' "${CHANGED_FILES:-}" \
|
|
35
|
+
| grep -E "$GATE5C_TEST_FILE_PATTERN" 2>/dev/null || true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# One-line-per-id extraction. Reads req-ids.json when jq is available and falls
|
|
39
|
+
# back to grep when it is not: `jq` missing used to disable the gate silently, and
|
|
40
|
+
# a JSON array of strings is simple enough to read without it.
|
|
41
|
+
gate_5c_req_ids() {
|
|
42
|
+
local file="$1"
|
|
43
|
+
if [ ! -f "$file" ]; then
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
if command -v jq >/dev/null 2>&1; then
|
|
47
|
+
jq -r '.[]' "$file" 2>/dev/null && return 0
|
|
48
|
+
fi
|
|
49
|
+
# Fallback: quoted strings inside the array. Good enough for a flat id list, and
|
|
50
|
+
# it fails toward EMPTY rather than toward a bogus id.
|
|
51
|
+
grep -oE '"[^"]+"' "$file" 2>/dev/null | tr -d '"' || true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# The @test REQ-XXX annotations inside one file.
|
|
55
|
+
gate_5c_annotations() {
|
|
56
|
+
local file="$1"
|
|
57
|
+
grep -oE '@test[[:space:]]+REQ-[A-Za-z0-9-]+' "$file" 2>/dev/null \
|
|
58
|
+
| sed 's/@test[[:space:]]*//' | sort -u || true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Gate 5c entry point. Echoes the verdict; returns 1 to BLOCK.
|
|
62
|
+
#
|
|
63
|
+
# Reads CHANGED_FILES (set by pre-commit). Writes GATE_5C_STATUS for the report:
|
|
64
|
+
# PASS, SKIP or BLOCK — never empty, because "empty" is what was broken.
|
|
65
|
+
gate_5c_alignment() {
|
|
66
|
+
local req_ids_file=".sprint-state/phase-outputs/req-ids.json"
|
|
67
|
+
GATE_5C_STATUS=""
|
|
68
|
+
|
|
69
|
+
if [ ! -f "$req_ids_file" ]; then
|
|
70
|
+
# The normal state of a fresh clone: .sprint-state/ is a process artifact and
|
|
71
|
+
# gitignored. Say so, out loud, with the command that would fix it.
|
|
72
|
+
echo " ⏭️ Gate 5c: spec/test alignment NOT CHECKED — $req_ids_file missing"
|
|
73
|
+
echo " Run 'xp-gate check-alignment' to generate it and enable this gate."
|
|
74
|
+
GATE_5C_STATUS="SKIP"
|
|
75
|
+
return 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Staleness: a req-ids.json older than the spec it claims to describe would let a
|
|
79
|
+
# deleted REQ keep its annotations looking valid.
|
|
80
|
+
if [ -f "specification.yaml" ] && [ "$req_ids_file" -ot "specification.yaml" ]; then
|
|
81
|
+
echo " ⏭️ Gate 5c: spec/test alignment NOT CHECKED — req-ids.json is stale"
|
|
82
|
+
echo " specification.yaml is newer. Run 'xp-gate check-alignment' to regenerate."
|
|
83
|
+
GATE_5C_STATUS="SKIP"
|
|
84
|
+
return 0
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
local test_files
|
|
88
|
+
test_files=$(gate_5c_test_files)
|
|
89
|
+
if [ -z "$test_files" ]; then
|
|
90
|
+
# Reported rather than silent: without this line the operator cannot tell
|
|
91
|
+
# "nothing to check" from "the gate is broken" (#585).
|
|
92
|
+
echo " ℹ️ Gate 5c: no test files among the staged changes — nothing to align."
|
|
93
|
+
GATE_5C_STATUS="SKIP"
|
|
94
|
+
return 0
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
echo " → Checking @test REQ-XXX annotations..."
|
|
98
|
+
local annotation_errors=0 tf req_id found
|
|
99
|
+
while read -r tf; do
|
|
100
|
+
[ -n "$tf" ] || continue
|
|
101
|
+
[ -f "$tf" ] || continue
|
|
102
|
+
while read -r req_id; do
|
|
103
|
+
[ -n "$req_id" ] || continue
|
|
104
|
+
found=$(gate_5c_req_ids "$req_ids_file" | grep -qxF "$req_id" && echo yes || true)
|
|
105
|
+
if [ -z "$found" ]; then
|
|
106
|
+
echo "❌ Gate 5c: $tf — @test $req_id: REQ ID not found in specification.yaml"
|
|
107
|
+
annotation_errors=$((annotation_errors + 1))
|
|
108
|
+
fi
|
|
109
|
+
done < <(gate_5c_annotations "$tf")
|
|
110
|
+
done < <(printf '%s\n' "$test_files")
|
|
111
|
+
|
|
112
|
+
if [ "$annotation_errors" -gt 0 ]; then
|
|
113
|
+
echo ""
|
|
114
|
+
echo "❌ $annotation_errors test annotation(s) reference non-existent REQ IDs — commit BLOCKED"
|
|
115
|
+
echo " Fix: verify the REQ IDs against specification.yaml, or run 'xp-gate check-alignment'"
|
|
116
|
+
GATE_5C_STATUS="BLOCK"
|
|
117
|
+
return 1
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Reverse check: REQs with no annotated test among the staged changes. Visible,
|
|
121
|
+
# non-blocking — a single commit rarely touches every requirement's test.
|
|
122
|
+
local all_reqs annotated_all="" untested=0 spec_req
|
|
123
|
+
all_reqs=$(gate_5c_req_ids "$req_ids_file")
|
|
124
|
+
while read -r tf; do
|
|
125
|
+
[ -n "$tf" ] || continue
|
|
126
|
+
[ -f "$tf" ] || continue
|
|
127
|
+
annotated_all="$annotated_all $(gate_5c_annotations "$tf")"
|
|
128
|
+
done < <(printf '%s\n' "$test_files")
|
|
129
|
+
|
|
130
|
+
while read -r spec_req; do
|
|
131
|
+
[ -n "$spec_req" ] || continue
|
|
132
|
+
if ! printf '%s\n' "$annotated_all" | tr ' ' '\n' | grep -qxF "$spec_req"; then
|
|
133
|
+
untested=$((untested + 1))
|
|
134
|
+
fi
|
|
135
|
+
done < <(printf '%s\n' "$all_reqs")
|
|
136
|
+
|
|
137
|
+
if [ "$untested" -gt 0 ]; then
|
|
138
|
+
echo " ⚠️ [FAIL] Gate 5c: $untested REQ(s) in specification.yaml have no annotated tests"
|
|
139
|
+
echo " This is non-blocking. Run 'xp-gate check-alignment' for the full report."
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
echo " ✅ @test annotation check complete."
|
|
143
|
+
GATE_5C_STATUS="PASS"
|
|
144
|
+
return 0
|
|
145
|
+
}
|
package/hooks/lib/jscpd-run.sh
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# ============================================================================
|
|
3
|
-
# Gate 2 duplicate-code helpers (issue #477)
|
|
3
|
+
# Gate 2 duplicate-code helpers (issue #477, #591)
|
|
4
4
|
#
|
|
5
5
|
# Extracted from pre-commit so the outcome contract is unit-testable without
|
|
6
6
|
# executing the hook, and so an installed hook can source it the same way it
|
|
7
7
|
# sources the other libraries in this directory.
|
|
8
8
|
# ============================================================================
|
|
9
9
|
|
|
10
|
+
# The default ignore set: what Gate 2 must NOT treat as refactorable source when
|
|
11
|
+
# the project expresses no preference (#591).
|
|
12
|
+
#
|
|
13
|
+
# jscpd's own defaults treat .yaml, .txt and .log as scannable formats. On a
|
|
14
|
+
# data-heavy project that reports an exemptions manifest, a golden snapshot and
|
|
15
|
+
# two build logs as "duplicate code" — measured as 48.72% duplication on a
|
|
16
|
+
# fixture that contained no source at all. The number is worse than noise: a
|
|
17
|
+
# reader cannot tell "yaml rows that must repeat" from "two methods that were
|
|
18
|
+
# really copy-pasted", so the honest response is to stop reading Gate 2 and take
|
|
19
|
+
# the real duplicates down with it. The reporter's own tally: 176 clones
|
|
20
|
+
# reported, 6 refactorable.
|
|
21
|
+
#
|
|
22
|
+
# Deliberately NARROW. Every entry is a file class whose duplication is its data
|
|
23
|
+
# shape or its artifact nature, never a refactoring target. Real source
|
|
24
|
+
# extensions are untouched — pinned by a control case, because an ignore set that
|
|
25
|
+
# also swallowed .ts/.java/.py would report a permanent clean and look healthier
|
|
26
|
+
# than before the fix.
|
|
27
|
+
JSCPD_DEFAULT_IGNORE_PATTERNS='(^|/)(node_modules|dist|build|target|coverage|out|\.next|\.nuxt|vendor)/|\.log$|\.txt$|\.(ya?ml)$|golden|baseline\.json$|evidence'
|
|
28
|
+
|
|
29
|
+
# Human-readable form, printed by the gate so the baseline is auditable (#591
|
|
30
|
+
# point 3). Silence about the default is the same defect as silence about a
|
|
31
|
+
# skipped gate: the user cannot answer "what did it scan".
|
|
32
|
+
jscpd_default_ignore_patterns() {
|
|
33
|
+
printf '%s\n' "jscpd default ignore (no jscpd.conf.json): build output (node_modules, dist, build, target, coverage, out, vendor), *.log, *.txt, *.yaml/*.yml, *golden*, *baseline.json, *evidence*"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Is this path covered by the default ignore set? Exit 0 = ignore it.
|
|
37
|
+
#
|
|
38
|
+
# XP_GATE_JSCPD_DEFAULT_IGNORE=0 disables the set entirely: a project that
|
|
39
|
+
# genuinely wants YAML compared must be able to say so, or the default would make
|
|
40
|
+
# some real duplicates unreachable.
|
|
41
|
+
jscpd_is_default_ignored() {
|
|
42
|
+
local _path="$1"
|
|
43
|
+
if [ "${XP_GATE_JSCPD_DEFAULT_IGNORE:-1}" = "0" ]; then
|
|
44
|
+
return 1
|
|
45
|
+
fi
|
|
46
|
+
printf '%s\n' "$_path" | grep -qE "$JSCPD_DEFAULT_IGNORE_PATTERNS"
|
|
47
|
+
}
|
|
48
|
+
|
|
10
49
|
# Echo the jscpd config the gate should pass, or nothing when the project has
|
|
11
50
|
# none.
|
|
12
51
|
#
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Convert a POSIX path into the form a NATIVE Windows binary can open.
|
|
3
|
+
#
|
|
4
|
+
# #581 / #582: on WSL and in Git-Bash, `node.exe` and `gitleaks.exe` cannot open
|
|
5
|
+
# `/mnt/d/projects/repo/...` or `/d/projects/repo/...`. They resolve a leading `/` to
|
|
6
|
+
# `<current drive>:\`, so the path silently becomes `D:\mnt\d\projects\...` and the
|
|
7
|
+
# module is "not found". The failure then gets reported as a verdict about the USER'S
|
|
8
|
+
# repository:
|
|
9
|
+
#
|
|
10
|
+
# Gate 9 ❌ BLOCKED - Build integrity check failed
|
|
11
|
+
# Fix type errors, broken imports, or package manifest issues
|
|
12
|
+
#
|
|
13
|
+
# Gate 8 ❌ BLOCKED - Secrets detected in staged files
|
|
14
|
+
#
|
|
15
|
+
# Both are wrong, and both are expensive to debug: the message points at the code,
|
|
16
|
+
# the cause is the shell. #582 fixed the Gate 8 instance inline; this library exists so
|
|
17
|
+
# the next caller does not have to re-derive it, and so there is ONE place that knows
|
|
18
|
+
# the converters — not one per gate that happened to notice.
|
|
19
|
+
#
|
|
20
|
+
# Relative paths are deliberately NOT converted. node resolves `scripts/x.cjs` against
|
|
21
|
+
# its own cwd, which it translated correctly; converting would be pointless work and a
|
|
22
|
+
# chance to get it wrong.
|
|
23
|
+
|
|
24
|
+
# Does this host need POSIX -> native conversion at all?
|
|
25
|
+
#
|
|
26
|
+
# True when a native binary is on the far side of the shell: WSL, or MSYS/Git-Bash on
|
|
27
|
+
# Windows. On a plain Linux/macOS host neither converter exists and no conversion is
|
|
28
|
+
# ever wanted.
|
|
29
|
+
#
|
|
30
|
+
# @returns 0 when conversion applies
|
|
31
|
+
native_path_applies() {
|
|
32
|
+
[ -n "${WSL_DISTRO_NAME:-}" ] && return 0
|
|
33
|
+
[ -n "${MSYSTEM:-}" ] && return 0
|
|
34
|
+
# Fallback: a converter being present IS the signal, and covers hosts that set
|
|
35
|
+
# neither marker (some CI images strip MSYSTEM).
|
|
36
|
+
command -v wslpath >/dev/null 2>&1 && return 0
|
|
37
|
+
command -v cygpath >/dev/null 2>&1 && return 0
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Convert a path to native Windows form, or echo it unchanged when that is not possible.
|
|
42
|
+
#
|
|
43
|
+
# Order matters. `wslpath` is WSL's converter; `cygpath` is MSYS/Git-Bash's. Asking each
|
|
44
|
+
# only on its own host avoids the case where both exist and the wrong one "succeeds"
|
|
45
|
+
# with a path the other binary cannot use.
|
|
46
|
+
#
|
|
47
|
+
# Never fails the caller: a path that cannot be converted is returned as-is, because a
|
|
48
|
+
# gate that aborts here would break on hosts that need no conversion at all. Callers that
|
|
49
|
+
# must know whether conversion happened should compare the input and output.
|
|
50
|
+
#
|
|
51
|
+
# @param {string} path path to convert
|
|
52
|
+
# @returns {string} native form, or the input unchanged
|
|
53
|
+
to_native_path() {
|
|
54
|
+
local _p="${1:-}"
|
|
55
|
+
[ -n "$_p" ] || return 0
|
|
56
|
+
# Only absolute paths are candidates. A relative path is resolved by the child
|
|
57
|
+
# process against a cwd it handles correctly.
|
|
58
|
+
case "$_p" in
|
|
59
|
+
/*) ;;
|
|
60
|
+
*) printf '%s' "$_p"; return 0 ;;
|
|
61
|
+
esac
|
|
62
|
+
|
|
63
|
+
local _converted=""
|
|
64
|
+
if [ -n "${WSL_DISTRO_NAME:-}" ] && command -v wslpath >/dev/null 2>&1; then
|
|
65
|
+
_converted=$(wslpath -w "$_p" 2>/dev/null || true)
|
|
66
|
+
elif command -v cygpath >/dev/null 2>&1; then
|
|
67
|
+
_converted=$(cygpath -w "$_p" 2>/dev/null || true)
|
|
68
|
+
elif command -v wslpath >/dev/null 2>&1; then
|
|
69
|
+
# wslpath without WSL_DISTRO_NAME: still the better guess than nothing.
|
|
70
|
+
_converted=$(wslpath -w "$_p" 2>/dev/null || true)
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
if [ -n "$_converted" ]; then
|
|
74
|
+
printf '%s' "$_converted"
|
|
75
|
+
else
|
|
76
|
+
printf '%s' "$_p"
|
|
77
|
+
fi
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Convert a path only when conversion is needed, and say so when it cannot be done.
|
|
81
|
+
#
|
|
82
|
+
# The stricter sibling: use this where a wrong path produces a MISLEADING verdict rather
|
|
83
|
+
# than a clean failure (Gate 9's "build integrity check failed", Gate 8's "secrets
|
|
84
|
+
# detected"). It prints a warning to stderr when the path is absolute, the host needs
|
|
85
|
+
# conversion, and nothing was converted — the case that silently becomes a false verdict.
|
|
86
|
+
#
|
|
87
|
+
# @param {string} path path to convert
|
|
88
|
+
# @param {string} what human name for the path, used in the warning
|
|
89
|
+
# @returns {string} converted path, or the input unchanged
|
|
90
|
+
to_native_path_or_warn() {
|
|
91
|
+
local _p="${1:-}"
|
|
92
|
+
local _what="${2:-path}"
|
|
93
|
+
local _out
|
|
94
|
+
_out=$(to_native_path "$_p")
|
|
95
|
+
if [ "$_out" = "$_p" ]; then
|
|
96
|
+
case "$_p" in
|
|
97
|
+
/*)
|
|
98
|
+
if native_path_applies; then
|
|
99
|
+
echo " ⚠️ could not convert $_what to native form: $_p" >&2
|
|
100
|
+
echo " a native binary may fail to open it; the failure will look like a code defect" >&2
|
|
101
|
+
fi
|
|
102
|
+
;;
|
|
103
|
+
esac
|
|
104
|
+
fi
|
|
105
|
+
printf '%s' "$_out"
|
|
106
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Score a pre-push gate list, or refuse to.
|
|
3
|
+
|
|
4
|
+
`overall.score` is the most quotable number in a pre-push report, so it has to
|
|
5
|
+
generalise over the gates it claims to summarise (#580, #577).
|
|
6
|
+
|
|
7
|
+
Two shapes must produce no number at all:
|
|
8
|
+
|
|
9
|
+
no gate ran -> 0.0/10 would claim "everything broke"
|
|
10
|
+
some gate ran, but the -> 10.0/10 off a single passing gate would claim
|
|
11
|
+
rest never did "everything is green"
|
|
12
|
+
|
|
13
|
+
The honest answer in both cases is None — "not measured" — because either way the
|
|
14
|
+
number would be a claim the run cannot support. A score is emitted only when the
|
|
15
|
+
gates that ran are a MAJORITY of the gates that exist, and it always travels with
|
|
16
|
+
`score_scope` naming what it covers, so no reader can quote it as global quality.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
prepush-score.py --status PASS --status SKIP ... # one per gate
|
|
20
|
+
prepush-score.py --report path/to/report.json # patch in place
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Statuses grouped by what they say about the run. Kept as data rather than as
|
|
32
|
+
# branches so adding a status is a one-line change and cannot silently fall
|
|
33
|
+
# through to "failed" (which is exactly what DEGRADED used to do, #574).
|
|
34
|
+
FAILED_STATUSES = ("FAIL", "BLOCK")
|
|
35
|
+
# A degradation is a gate that ran but could not fully do its job, so it counts
|
|
36
|
+
# with the warnings: a degraded gate must never be judged a hard failure.
|
|
37
|
+
DEGRADED_STATUSES = ("DEGRADED", "WARN")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _group_by_disposition(gates: Sequence[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
|
|
41
|
+
"""Split the gate list into ran / passed / failed / warned / degraded / skipped."""
|
|
42
|
+
def with_status(statuses: Sequence[str]) -> List[Dict[str, Any]]:
|
|
43
|
+
return [g for g in gates if g.get("status") in statuses]
|
|
44
|
+
|
|
45
|
+
ran = [g for g in gates if g.get("status") != "SKIP"]
|
|
46
|
+
return {
|
|
47
|
+
"ran": ran,
|
|
48
|
+
"passed": with_status(("PASS",)),
|
|
49
|
+
"failed": with_status(FAILED_STATUSES),
|
|
50
|
+
"warned": with_status(("WARN",)),
|
|
51
|
+
"degraded": with_status(DEGRADED_STATUSES),
|
|
52
|
+
"skipped": with_status(("SKIP",)),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _score_for(ran: Sequence[Dict[str, Any]], passed_count: int, total: int) -> Tuple[Optional[str], str]:
|
|
57
|
+
"""The score and the scope it covers, or (None, reason) when it cannot apply.
|
|
58
|
+
|
|
59
|
+
A score speaks for the gates that ran, so it is only emitted when those can
|
|
60
|
+
summarise the report. Two shapes cannot:
|
|
61
|
+
|
|
62
|
+
nothing ran -> 0.0/10 would claim "everything broke"
|
|
63
|
+
fewer than half ran -> 10.0/10 off one passing gate would claim
|
|
64
|
+
"everything is green"
|
|
65
|
+
|
|
66
|
+
Six of twelve is a tie, not an endorsement, hence `* 2 <=`.
|
|
67
|
+
"""
|
|
68
|
+
if not ran:
|
|
69
|
+
return None, "not measured"
|
|
70
|
+
if len(ran) * 2 <= total:
|
|
71
|
+
return None, "%d of %d gates is too few to summarise" % (len(ran), total)
|
|
72
|
+
return "%.1f/10" % (10.0 * passed_count / len(ran)), "%d of %d gates" % (len(ran), total)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compute(gates: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
|
|
76
|
+
"""Return the score fields for a gate list.
|
|
77
|
+
|
|
78
|
+
Keys: score (str | None), score_scope (str), gates_run, gates_skipped,
|
|
79
|
+
gates_total, gates_passed, gates_failed, gates_warned, gates_degraded,
|
|
80
|
+
failed_gates, skipped_gates. The rule lives here and nowhere else.
|
|
81
|
+
|
|
82
|
+
`gates_total` and `gates_passed` are counted from the list rather than taken
|
|
83
|
+
from the caller: they used to be a hardcoded 8 and a 1/0 squashed out of the
|
|
84
|
+
verdict, so a report could claim eight gates while listing twelve (#569).
|
|
85
|
+
"""
|
|
86
|
+
total = len(gates)
|
|
87
|
+
groups = _group_by_disposition(gates)
|
|
88
|
+
score, scope = _score_for(groups["ran"], len(groups["passed"]), total)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"score": score,
|
|
92
|
+
"score_scope": scope,
|
|
93
|
+
"gates_run": len(groups["ran"]),
|
|
94
|
+
"gates_skipped": len(groups["skipped"]),
|
|
95
|
+
"gates_total": total,
|
|
96
|
+
"gates_passed": len(groups["passed"]),
|
|
97
|
+
"gates_failed": len(groups["failed"]),
|
|
98
|
+
"gates_warned": len(groups["warned"]),
|
|
99
|
+
"gates_degraded": len(groups["degraded"]),
|
|
100
|
+
"failed_gates": [g.get("id") for g in groups["failed"]],
|
|
101
|
+
"skipped_gates": [g.get("id") for g in groups["skipped"]],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def apply_to_report(report: Dict[str, Any]) -> Dict[str, Any]:
|
|
106
|
+
"""Patch the score fields into a report's `overall` section, in place."""
|
|
107
|
+
overall = report.setdefault("overall", {})
|
|
108
|
+
overall.update(compute(report.get("gates", [])))
|
|
109
|
+
return report
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: Optional[Iterable[str]] = None) -> int:
|
|
113
|
+
parser = argparse.ArgumentParser(description="Score a pre-push gate list.")
|
|
114
|
+
parser.add_argument(
|
|
115
|
+
"--status",
|
|
116
|
+
action="append",
|
|
117
|
+
default=[],
|
|
118
|
+
metavar="STATUS",
|
|
119
|
+
help="status of one gate (repeatable, in gate order)",
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--report",
|
|
123
|
+
metavar="PATH",
|
|
124
|
+
help="patch the score fields into this report JSON in place",
|
|
125
|
+
)
|
|
126
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
127
|
+
|
|
128
|
+
if args.report:
|
|
129
|
+
path = Path(args.report)
|
|
130
|
+
report = json.loads(path.read_text(encoding="utf-8"))
|
|
131
|
+
apply_to_report(report)
|
|
132
|
+
path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
if not args.status:
|
|
136
|
+
parser.error("either --report or at least one --status is required")
|
|
137
|
+
|
|
138
|
+
print(json.dumps(compute([{"status": s} for s in args.status])))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
|
@@ -56,3 +56,26 @@ sprint_gate_detail() {
|
|
|
56
56
|
done
|
|
57
57
|
return 0
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
# Echo WHY sprint-gate.sh allowed the commit (#584).
|
|
61
|
+
#
|
|
62
|
+
# `allow` on its own is ambiguous: it certifies a validated sprint, but it also
|
|
63
|
+
# covers "the sprint has not reached this gate's phase" and "no JSON parser, so
|
|
64
|
+
# nothing could be read". pre-commit mapped every one of those to Gate 11 PASS,
|
|
65
|
+
# so a gate that examined nothing raised the score and left the verdict alone.
|
|
66
|
+
#
|
|
67
|
+
# Only `checks-passed` licenses a PASS. Anything else -- including an `allow`
|
|
68
|
+
# from an older sprint-gate.sh that carries no reason at all -- means "not
|
|
69
|
+
# evaluated", and the caller must report SKIP. Defaulting the other way would
|
|
70
|
+
# reinstate the defect for every consumer who has not re-installed.
|
|
71
|
+
sprint_gate_allow_reason() {
|
|
72
|
+
local _out="${1:-}" _reason
|
|
73
|
+
_reason=$(printf '%s' "$_out" | sed -n 's/.*"allow_reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tail -1)
|
|
74
|
+
if [ -n "$_reason" ]; then
|
|
75
|
+
printf '%s\n' "$_reason"
|
|
76
|
+
return 0
|
|
77
|
+
fi
|
|
78
|
+
# No reason given. The decision may still be an `allow`; the caller decides.
|
|
79
|
+
printf 'unspecified\n'
|
|
80
|
+
return 0
|
|
81
|
+
}
|