@boyingliu01/xp-gate 0.15.4 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/xp-gate.js +29 -1
- package/hooks/lib/now-ms.sh +33 -0
- package/hooks/pre-commit +63 -47
- package/hooks/sprint-gate.sh +291 -0
- package/lib/__tests__/check-version.test.js +122 -0
- package/lib/__tests__/doctor.test.js +13 -15
- package/lib/__tests__/gate-audit.test.ts +38 -0
- package/lib/__tests__/phase-transition.test.js +241 -1
- package/lib/__tests__/retro.test.js +131 -0
- package/lib/__tests__/sprint-status-rework.test.js +184 -0
- package/lib/check-version.js +9 -0
- package/lib/doctor-tui.js +79 -9
- package/lib/doctor.js +15 -3
- package/lib/gate-audit.ts +18 -7
- package/lib/gate-runner.js +77 -30
- package/lib/init.js +10 -0
- package/lib/mattpocock-license-verdict.json +7 -0
- package/lib/phase-transition.js +251 -1
- package/lib/retro.js +222 -0
- package/lib/shared-utils.js +7 -0
- package/lib/sprint-init.js +207 -0
- package/lib/sprint-state-migrator.js +1 -0
- package/lib/sprint-status.js +111 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +3 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +59 -4
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +390 -0
- package/plugins/claude-code/skills/delphi-review/references/round-templates.md +67 -0
- package/plugins/claude-code/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/claude-code/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/claude-code/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/claude-code/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/claude-code/skills/grilling/SKILL.md +12 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +168 -43
- package/plugins/claude-code/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/claude-code/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/claude-code/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/plugins/claude-code/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/plugins/claude-code/skills/sprint-flow/references/phase-overview.md +58 -53
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +8 -3
- package/plugins/claude-code/skills/to-issues/SKILL.md +1 -1
- package/plugins/opencode/index.ts +1 -0
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +59 -4
- package/plugins/opencode/skills/delphi-review/references/requirements.md +390 -0
- package/plugins/opencode/skills/delphi-review/references/round-templates.md +67 -0
- package/plugins/opencode/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/opencode/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/opencode/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/opencode/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/opencode/skills/grilling/SKILL.md +12 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/SKILL.md +168 -43
- package/plugins/opencode/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/opencode/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/opencode/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/plugins/opencode/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/plugins/opencode/skills/sprint-flow/references/phase-overview.md +58 -53
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/SKILL.md +8 -3
- package/plugins/opencode/skills/to-issues/SKILL.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/__tests__/check-mattpocock-license.test.js +229 -0
- package/scripts/__tests__/sync-package-content.test.js +50 -0
- package/scripts/check-mattpocock-license.cjs +302 -0
- package/scripts/migrate-audit-anomalies.mjs +78 -0
- package/scripts/sync-package-content.js +52 -5
- package/skills/batch-grill-me/SKILL.md +15 -0
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +59 -4
- package/skills/delphi-review/references/requirements.md +390 -0
- package/skills/delphi-review/references/round-templates.md +67 -0
- package/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/skills/domain-modeling/SKILL.md +74 -0
- package/skills/grill-with-docs/SKILL.md +7 -0
- package/skills/grilling/SKILL.md +12 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/SKILL.md +168 -43
- package/skills/sprint-flow/references/components/memory.md +1 -1
- package/skills/sprint-flow/references/components/middleware.md +1 -1
- package/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/skills/sprint-flow/references/orchestration-rules.md +14 -12
- package/skills/sprint-flow/references/phase-2-design.md +145 -133
- package/skills/sprint-flow/references/phase-3-build.md +69 -2
- package/skills/sprint-flow/references/phase-4-verify.md +93 -31
- package/skills/sprint-flow/references/phase-5-ship.md +99 -14
- package/skills/sprint-flow/references/phase-6-close.md +37 -5
- package/skills/sprint-flow/references/phase-overview.md +58 -53
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/skills/test-specification-alignment/SKILL.md +8 -3
- package/skills/to-issues/SKILL.md +1 -1
package/bin/xp-gate.js
CHANGED
|
@@ -147,7 +147,23 @@ const COMMANDS = {
|
|
|
147
147
|
const { handleSprintStatus } = require('../lib/sprint-status.js');
|
|
148
148
|
handleSprintStatus(subargs).then(code => process.exit(code));
|
|
149
149
|
},
|
|
150
|
-
usage: 'xp-gate sprint-status [--json] [--watch] [--dir <path>]'
|
|
150
|
+
usage: 'xp-gate sprint-status [--json] [--watch] [--dir <path>] [--rework-check] [--window-days N]'
|
|
151
|
+
},
|
|
152
|
+
'retro': {
|
|
153
|
+
description: 'Generate sprint engineering retrospective (#369)',
|
|
154
|
+
run: subargs => {
|
|
155
|
+
const { handleRetro } = require('../lib/retro.js');
|
|
156
|
+
handleRetro(subargs).then(code => process.exit(code));
|
|
157
|
+
},
|
|
158
|
+
usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
|
|
159
|
+
},
|
|
160
|
+
'sprint-init': {
|
|
161
|
+
description: 'Initialize sprint state (single entry point for sprint creation, resolves #366)',
|
|
162
|
+
run: subargs => {
|
|
163
|
+
const { handleSprintInit } = require('../lib/sprint-init.js');
|
|
164
|
+
handleSprintInit(subargs).then(code => process.exit(code));
|
|
165
|
+
},
|
|
166
|
+
usage: 'xp-gate sprint-init "<task_description>" [--issues "<#123>"] [--force] [--dry-run] [--dir <path>]'
|
|
151
167
|
},
|
|
152
168
|
'next-sprint': {
|
|
153
169
|
description: 'Analyze remaining open issues and plan next iteration',
|
|
@@ -248,6 +264,18 @@ for (let i = 0; i <= 11; i++) {
|
|
|
248
264
|
};
|
|
249
265
|
}
|
|
250
266
|
|
|
267
|
+
// Named utility gates (analytics/report tools)
|
|
268
|
+
for (const gateId of ['python-health', 'test-layers', 'pbt']) {
|
|
269
|
+
const gateInfo = gateRunner.GATE_REGISTRY[gateId];
|
|
270
|
+
if (gateInfo) {
|
|
271
|
+
COMMANDS[gateId] = {
|
|
272
|
+
description: gateInfo.description,
|
|
273
|
+
run: subargs => gateRunner.runGate(gateId, subargs[0]).then(code => process.exit(code)),
|
|
274
|
+
usage: `xp-gate ${gateId} [<path>]`
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
251
279
|
function printHelp() {
|
|
252
280
|
console.log('xp-gate - AI development workflow tool');
|
|
253
281
|
console.log('');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# now_ms() — Cross-platform epoch-milliseconds timestamp (Issue #370 fix)
|
|
4
|
+
#
|
|
5
|
+
# Windows Git Bash's `date +%s%3N` outputs literal "N" (e.g. "1753001234N")
|
|
6
|
+
# and exits 0, so || fallback chains never trigger. This function:
|
|
7
|
+
# 1. Prefers `node -e 'console.log(Date.now())'` (cross-platform, always ms)
|
|
8
|
+
# 2. Validates output with ^[0-9]+$ regex
|
|
9
|
+
# 3. Falls back to `date +%s` (seconds × 1000) if node output is invalid
|
|
10
|
+
# 4. Final fallback: echo 0
|
|
11
|
+
#
|
|
12
|
+
# Source this file: source "$(dirname "${BASH_SOURCE[0]}")/lib/now-ms.sh"
|
|
13
|
+
# ============================================================================
|
|
14
|
+
|
|
15
|
+
now_ms() {
|
|
16
|
+
local ts
|
|
17
|
+
# Preferred: node (cross-platform, always produces epoch milliseconds)
|
|
18
|
+
ts=$(node -e 'console.log(Date.now())' 2>/dev/null)
|
|
19
|
+
if [[ "$ts" =~ ^[0-9]+$ ]]; then
|
|
20
|
+
echo "$ts"
|
|
21
|
+
return
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# Fallback: date +%s (seconds only — multiply by 1000 for ms)
|
|
25
|
+
ts=$(date +%s 2>/dev/null)
|
|
26
|
+
if [[ "$ts" =~ ^[0-9]+$ ]]; then
|
|
27
|
+
echo $((ts * 1000))
|
|
28
|
+
return
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# Final fallback
|
|
32
|
+
echo 0
|
|
33
|
+
}
|
package/hooks/pre-commit
CHANGED
|
@@ -165,6 +165,69 @@ file_exists_in_subdir() {
|
|
|
165
165
|
fi
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
# ============================================================================
|
|
169
|
+
# Audit/Timing helpers — must be defined BEFORE first gate call (line ~197)
|
|
170
|
+
# Bash executes top-to-bottom; gate_start_ms + record_gate_audit are called
|
|
171
|
+
# in every gate, so their definitions lead the first call.
|
|
172
|
+
# ============================================================================
|
|
173
|
+
AUDIT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
174
|
+
PROJECT_ROOT_FOR_AUDIT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$AUDIT_SCRIPT_DIR/../..")"
|
|
175
|
+
|
|
176
|
+
# Issue #370: source cross-platform now_ms() helper (node-preferred, regex-validated)
|
|
177
|
+
source "${AUDIT_SCRIPT_DIR}/lib/now-ms.sh"
|
|
178
|
+
|
|
179
|
+
# Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
|
|
180
|
+
_MAX_DURATION_MS=$(node -e "try{const c=JSON.parse(require('fs').readFileSync('${PROJECT_ROOT_FOR_AUDIT}/.xp-gate-config.json','utf8'));console.log((c.audit&&c.audit.max_duration_ms)||7200000);}catch(e){console.log(7200000);}" 2>/dev/null || echo "7200000")
|
|
181
|
+
|
|
182
|
+
# Get milliseconds timestamp (used by gates for duration measurement)
|
|
183
|
+
# Issue #370: delegates to now_ms() — node-preferred, regex-validated, cross-platform
|
|
184
|
+
gate_start_ms() {
|
|
185
|
+
now_ms
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Audit helper — records gate execution to .xp-gate/audit.jsonl
|
|
189
|
+
# NEVER blocks commit on failure (try/catch around npx tsx).
|
|
190
|
+
# Usage: record_gate_audit <gate_id> <gate_name> <passed> <issues_found> <start_ms>
|
|
191
|
+
record_gate_audit() {
|
|
192
|
+
local gate_id="$1"
|
|
193
|
+
local gate_name="$2"
|
|
194
|
+
local passed="$3"
|
|
195
|
+
local issues_found="$4"
|
|
196
|
+
local start_ms="$5"
|
|
197
|
+
local end_ms
|
|
198
|
+
end_ms=$(now_ms)
|
|
199
|
+
local duration_ms=$((end_ms - start_ms))
|
|
200
|
+
if [ "$duration_ms" -lt 0 ] 2>/dev/null; then
|
|
201
|
+
duration_ms=0
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# Issue #370: detect anomalous duration (poisoned timestamp arithmetic)
|
|
205
|
+
local duration_anomaly_flag=""
|
|
206
|
+
if [ "$duration_ms" -gt "$_MAX_DURATION_MS" ] 2>/dev/null; then
|
|
207
|
+
duration_anomaly_flag="--duration-anomaly true"
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
# Determine gate result: PASS or FAIL based on status variable
|
|
211
|
+
local gate_result="true"
|
|
212
|
+
if [ "$passed" != "PASS" ] && [ "$passed" != "SKIP" ]; then
|
|
213
|
+
gate_result="false"
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
# Fire-and-forget: audit must never block the commit
|
|
217
|
+
{
|
|
218
|
+
npx tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
|
|
219
|
+
--gate-id "$gate_id" \
|
|
220
|
+
--gate-name "$gate_name" \
|
|
221
|
+
--passed "$gate_result" \
|
|
222
|
+
--issues-found "$issues_found" \
|
|
223
|
+
--duration-ms "$duration_ms" \
|
|
224
|
+
$duration_anomaly_flag \
|
|
225
|
+
--trigger commit \
|
|
226
|
+
2>/dev/null
|
|
227
|
+
} &
|
|
228
|
+
disown 2>/dev/null || true
|
|
229
|
+
}
|
|
230
|
+
|
|
168
231
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
169
232
|
echo " QUALITY GATES - PRE-COMMIT CHECK"
|
|
170
233
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
@@ -713,53 +776,6 @@ has_project_lang() {
|
|
|
713
776
|
done
|
|
714
777
|
return 1
|
|
715
778
|
}
|
|
716
|
-
|
|
717
|
-
# ============================================================================
|
|
718
|
-
# Audit helper — records gate execution to .xp-gate/audit.jsonl
|
|
719
|
-
# NEVER blocks commit on failure (try/catch around npx tsx).
|
|
720
|
-
# Usage: record_gate_audit <gate_id> <gate_name> <passed> <issues_found> <start_ms>
|
|
721
|
-
# ============================================================================
|
|
722
|
-
AUDIT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
723
|
-
PROJECT_ROOT_FOR_AUDIT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$AUDIT_SCRIPT_DIR/../..")"
|
|
724
|
-
|
|
725
|
-
record_gate_audit() {
|
|
726
|
-
local gate_id="$1"
|
|
727
|
-
local gate_name="$2"
|
|
728
|
-
local passed="$3"
|
|
729
|
-
local issues_found="$4"
|
|
730
|
-
local start_ms="$5"
|
|
731
|
-
local end_ms
|
|
732
|
-
end_ms=$(date +%s%3N 2>/dev/null || node -e "console.log(Date.now())" 2>/dev/null || echo "0")
|
|
733
|
-
local duration_ms=$((end_ms - start_ms))
|
|
734
|
-
if [ "$duration_ms" -lt 0 ] 2>/dev/null; then
|
|
735
|
-
duration_ms=0
|
|
736
|
-
fi
|
|
737
|
-
|
|
738
|
-
# Determine gate result: PASS or FAIL based on status variable
|
|
739
|
-
local gate_result="true"
|
|
740
|
-
if [ "$passed" != "PASS" ] && [ "$passed" != "SKIP" ]; then
|
|
741
|
-
gate_result="false"
|
|
742
|
-
fi
|
|
743
|
-
|
|
744
|
-
# Fire-and-forget: audit must never block the commit
|
|
745
|
-
{
|
|
746
|
-
npx tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
|
|
747
|
-
--gate-id "$gate_id" \
|
|
748
|
-
--gate-name "$gate_name" \
|
|
749
|
-
--passed "$gate_result" \
|
|
750
|
-
--issues-found "$issues_found" \
|
|
751
|
-
--duration-ms "$duration_ms" \
|
|
752
|
-
--trigger commit \
|
|
753
|
-
2>/dev/null
|
|
754
|
-
} &
|
|
755
|
-
disown 2>/dev/null || true
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
# Get milliseconds timestamp (used by gates for duration measurement)
|
|
759
|
-
gate_start_ms() {
|
|
760
|
-
date +%s%3N 2>/dev/null || node -e "console.log(Date.now())" 2>/dev/null || echo "0"
|
|
761
|
-
}
|
|
762
|
-
|
|
763
779
|
# Switch to subdirectory if detected (for single-language subdirectory projects).
|
|
764
780
|
# Multi-language projects stay at root — each adapter handles its own path.
|
|
765
781
|
ORIGINAL_DIR=""
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# sprint-gate.sh — Sprint Flow Validation Gate
|
|
3
|
+
#
|
|
4
|
+
# PURPOSE: Enforce Sprint Flow phase discipline at git hook level.
|
|
5
|
+
# Validates delphi-review approval, sprint state, and specification
|
|
6
|
+
# artifacts before allowing commits and pushes on sprint branches.
|
|
7
|
+
#
|
|
8
|
+
# INTEGRATION: Called from pre-commit (Gate 10) and pre-push (Gate S),
|
|
9
|
+
# or run independently for CI/manual validation.
|
|
10
|
+
#
|
|
11
|
+
# USAGE:
|
|
12
|
+
# sprint-gate.sh --pre-commit # Validate before commit
|
|
13
|
+
# sprint-gate.sh --pre-push # Validate before push
|
|
14
|
+
#
|
|
15
|
+
# MECHANISM:
|
|
16
|
+
# --pre-commit:
|
|
17
|
+
# - If .sprint-state/ missing → SKIP (not a sprint project)
|
|
18
|
+
# - If sprint-state.json missing/invalid → SKIP (non-sprint commit)
|
|
19
|
+
# - If phase >= 1 (PLAN completed) and delphi-reviewed.json missing → DENY
|
|
20
|
+
# - If delphi-reviewed.json exists, verdict must be "APPROVED"
|
|
21
|
+
# - If jq AND node unavailable → WARN but ALLOW (graceful degradation)
|
|
22
|
+
# - If jq unavailable but node available → use Node.js JSON parser fallback
|
|
23
|
+
#
|
|
24
|
+
# --pre-push:
|
|
25
|
+
# - If .sprint-state/ missing → SKIP
|
|
26
|
+
# - If branch doesn't match sprint/* → SKIP
|
|
27
|
+
# - If delphi-reviewed.json missing or verdict != "APPROVED" → DENY
|
|
28
|
+
# - If phase < 2 (BUILD not started) → DENY
|
|
29
|
+
# - If phase >= 2 and specification.yaml missing → DENY
|
|
30
|
+
# - If jq AND node unavailable → DENY (cannot verify verdict)
|
|
31
|
+
#
|
|
32
|
+
# GRACEFUL DEGRADATION:
|
|
33
|
+
# - If .sprint-state/ directory doesn't exist → SKIP (not a sprint project)
|
|
34
|
+
# - If sprint-state.json is missing/invalid → SKIP (non-sprint commit)
|
|
35
|
+
# - JSON parser fallback chain: jq → Node.js → explicit degradation message
|
|
36
|
+
#
|
|
37
|
+
# OUTPUT: JSON to stdout — {"decision":"allow|deny|skip",...}
|
|
38
|
+
# EXIT: 0 = pass/skip, 1 = block
|
|
39
|
+
|
|
40
|
+
set -euo pipefail
|
|
41
|
+
|
|
42
|
+
# --- Argument parsing ---
|
|
43
|
+
MODE=""
|
|
44
|
+
case "${1:-}" in
|
|
45
|
+
--pre-commit) MODE="pre-commit" ;;
|
|
46
|
+
--pre-push) MODE="pre-push" ;;
|
|
47
|
+
*)
|
|
48
|
+
echo '{"decision":"deny","reason":"Usage: sprint-gate.sh --pre-commit | --pre-push"}'
|
|
49
|
+
exit 1
|
|
50
|
+
;;
|
|
51
|
+
esac
|
|
52
|
+
|
|
53
|
+
# --- Locate repo root and sprint state ---
|
|
54
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
|
55
|
+
echo '{"decision":"skip","reason":"not inside a git repository"}'
|
|
56
|
+
exit 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
SPRINT_STATE_DIR="$REPO_ROOT/.sprint-state"
|
|
60
|
+
SPRINT_STATE_JSON="$SPRINT_STATE_DIR/sprint-state.json"
|
|
61
|
+
APPROVED_FILE="$SPRINT_STATE_DIR/delphi-reviewed.json"
|
|
62
|
+
|
|
63
|
+
# If no .sprint-state directory, this isn't a sprint project → SKIP
|
|
64
|
+
if [ ! -d "$SPRINT_STATE_DIR" ]; then
|
|
65
|
+
echo '{"decision":"skip","reason":"not a sprint project"}'
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# --- Helper: check tool availability ---
|
|
70
|
+
HAS_JQ=false
|
|
71
|
+
if command -v jq &>/dev/null; then
|
|
72
|
+
HAS_JQ=true
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
HAS_NODE=false
|
|
76
|
+
if command -v node &>/dev/null; then
|
|
77
|
+
HAS_NODE=true
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# JSON_PARSER: "jq" | "node" | "none"
|
|
81
|
+
if $HAS_JQ; then
|
|
82
|
+
JSON_PARSER="jq"
|
|
83
|
+
elif $HAS_NODE; then
|
|
84
|
+
JSON_PARSER="node"
|
|
85
|
+
else
|
|
86
|
+
JSON_PARSER="none"
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# --- Helper: validate JSON file ---
|
|
90
|
+
# Returns 0 if valid, 1 if invalid or no parser available
|
|
91
|
+
json_validate() {
|
|
92
|
+
local file="$1"
|
|
93
|
+
if [ ! -f "$file" ]; then
|
|
94
|
+
return 1
|
|
95
|
+
fi
|
|
96
|
+
case "$JSON_PARSER" in
|
|
97
|
+
jq)
|
|
98
|
+
jq empty "$file" 2>/dev/null
|
|
99
|
+
return $?
|
|
100
|
+
;;
|
|
101
|
+
node)
|
|
102
|
+
node -e "try{JSON.parse(require('fs').readFileSync('$file','utf8'));process.exit(0)}catch(e){process.exit(1)}" 2>/dev/null
|
|
103
|
+
return $?
|
|
104
|
+
;;
|
|
105
|
+
*)
|
|
106
|
+
return 1
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# --- Helper: extract JSON field ---
|
|
112
|
+
# Usage: json_extract <file> <jq-filter>
|
|
113
|
+
# jq-filter is a jq expression like '.phase // -1' or '.verdict // ""'
|
|
114
|
+
# Falls back to Node.js when jq is unavailable
|
|
115
|
+
json_extract() {
|
|
116
|
+
local file="$1"
|
|
117
|
+
local filter="$2"
|
|
118
|
+
case "$JSON_PARSER" in
|
|
119
|
+
jq)
|
|
120
|
+
jq -r "$filter" "$file" 2>/dev/null
|
|
121
|
+
return $?
|
|
122
|
+
;;
|
|
123
|
+
node)
|
|
124
|
+
# Translate simple jq filters to Node.js
|
|
125
|
+
# Supports: .field // default patterns
|
|
126
|
+
local field default_val
|
|
127
|
+
field=$(echo "$filter" | sed 's/^\.//;s/ *\/\/.*$//')
|
|
128
|
+
default_val=$(echo "$filter" | sed -n 's/.*\/\/ *//p')
|
|
129
|
+
node -e "
|
|
130
|
+
try {
|
|
131
|
+
var d=JSON.parse(require('fs').readFileSync('$file','utf8'));
|
|
132
|
+
var v=d['${field}'];
|
|
133
|
+
if(v===undefined||v===null){v=${default_val:-null}}
|
|
134
|
+
process.stdout.write(String(v));
|
|
135
|
+
}catch(e){process.stdout.write('${default_val:-}');process.exit(1)}
|
|
136
|
+
" 2>/dev/null
|
|
137
|
+
return $?
|
|
138
|
+
;;
|
|
139
|
+
*)
|
|
140
|
+
echo ""
|
|
141
|
+
return 1
|
|
142
|
+
;;
|
|
143
|
+
esac
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# --- Helper: read phase from sprint-state.json ---
|
|
147
|
+
# Returns phase number or -1 if unreadable
|
|
148
|
+
read_phase() {
|
|
149
|
+
if [ ! -f "$SPRINT_STATE_JSON" ]; then
|
|
150
|
+
echo "-1"
|
|
151
|
+
return
|
|
152
|
+
fi
|
|
153
|
+
if [ "$JSON_PARSER" = "none" ]; then
|
|
154
|
+
echo "-1"
|
|
155
|
+
return
|
|
156
|
+
fi
|
|
157
|
+
if ! json_validate "$SPRINT_STATE_JSON"; then
|
|
158
|
+
echo "-1"
|
|
159
|
+
return
|
|
160
|
+
fi
|
|
161
|
+
local phase
|
|
162
|
+
phase=$(json_extract "$SPRINT_STATE_JSON" '.phase // -1') || phase="-1"
|
|
163
|
+
echo "$phase"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# --- Helper: read delphi verdict ---
|
|
167
|
+
# Returns verdict string or empty if unreadable
|
|
168
|
+
read_verdict() {
|
|
169
|
+
if [ ! -f "$APPROVED_FILE" ]; then
|
|
170
|
+
echo ""
|
|
171
|
+
return
|
|
172
|
+
fi
|
|
173
|
+
if [ "$JSON_PARSER" = "none" ]; then
|
|
174
|
+
echo ""
|
|
175
|
+
return
|
|
176
|
+
fi
|
|
177
|
+
if ! json_validate "$APPROVED_FILE"; then
|
|
178
|
+
echo ""
|
|
179
|
+
return
|
|
180
|
+
fi
|
|
181
|
+
json_extract "$APPROVED_FILE" '.verdict // ""' || echo ""
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# --- Pre-commit mode ---
|
|
185
|
+
if [ "$MODE" = "pre-commit" ]; then
|
|
186
|
+
PHASE=$(read_phase)
|
|
187
|
+
|
|
188
|
+
# If sprint-state.json is missing or invalid, skip (non-sprint commit)
|
|
189
|
+
if [ "$PHASE" = "-1" ]; then
|
|
190
|
+
if [ ! -f "$SPRINT_STATE_JSON" ]; then
|
|
191
|
+
echo '{"decision":"skip","reason":"sprint-state.json not found, non-sprint commit"}'
|
|
192
|
+
else
|
|
193
|
+
echo '{"decision":"skip","warning":"sprint-state.json is not valid JSON or no JSON parser available (install jq or node), allowing commit"}'
|
|
194
|
+
fi
|
|
195
|
+
exit 0
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# Phase >= 1 means PLAN is completed — delphi-review must be APPROVED
|
|
199
|
+
if [ "$PHASE" -ge 1 ] 2>/dev/null; then
|
|
200
|
+
# Check if delphi-reviewed.json exists
|
|
201
|
+
if [ ! -f "$APPROVED_FILE" ]; then
|
|
202
|
+
echo '{"decision":"deny","reason":"delphi-review not APPROVED. Complete Phase 1 before committing code changes."}'
|
|
203
|
+
exit 1
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# If no JSON parser available, warn but allow (graceful degradation)
|
|
207
|
+
if [ "$JSON_PARSER" = "none" ]; then
|
|
208
|
+
echo '{"decision":"allow","warning":"neither jq nor node available, cannot verify delphi-review verdict. Install jq or node for full protection."}'
|
|
209
|
+
exit 0
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
# Validate delphi-reviewed.json is valid JSON
|
|
213
|
+
if ! json_validate "$APPROVED_FILE"; then
|
|
214
|
+
echo '{"decision":"deny","reason":"delphi-reviewed.json is not valid JSON. Re-run: /delphi-review"}'
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# Check verdict
|
|
219
|
+
VERDICT=$(read_verdict)
|
|
220
|
+
if [ "$VERDICT" != "APPROVED" ]; then
|
|
221
|
+
echo "{\"decision\":\"deny\",\"reason\":\"delphi-review verdict is '${VERDICT}', not APPROVED. Fix issues and re-run: /delphi-review\"}"
|
|
222
|
+
exit 1
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
# All checks passed
|
|
226
|
+
echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated\"}"
|
|
227
|
+
exit 0
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
# Phase < 1 (pre-PLAN) — no delphi enforcement needed
|
|
231
|
+
echo "{\"decision\":\"allow\",\"reason\":\"sprint phase ${PHASE} (pre-PLAN), no delphi enforcement required\"}"
|
|
232
|
+
exit 0
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
# --- Pre-push mode ---
|
|
236
|
+
if [ "$MODE" = "pre-push" ]; then
|
|
237
|
+
# Only enforce on sprint/* branches
|
|
238
|
+
BRANCH="$(git branch --show-current 2>/dev/null)" || BRANCH=""
|
|
239
|
+
case "$BRANCH" in
|
|
240
|
+
sprint/*) ;; # continue
|
|
241
|
+
*)
|
|
242
|
+
echo "{\"decision\":\"skip\",\"reason\":\"branch '${BRANCH}' does not match sprint/* pattern\"}"
|
|
243
|
+
exit 0
|
|
244
|
+
;;
|
|
245
|
+
esac
|
|
246
|
+
|
|
247
|
+
# Validate sprint-state.json exists and is readable
|
|
248
|
+
PHASE=$(read_phase)
|
|
249
|
+
if [ "$PHASE" = "-1" ] && [ ! -f "$SPRINT_STATE_JSON" ]; then
|
|
250
|
+
echo "{\"decision\":\"deny\",\"reason\":\"sprint-state.json not found on sprint branch '${BRANCH}'\"}"
|
|
251
|
+
exit 1
|
|
252
|
+
fi
|
|
253
|
+
|
|
254
|
+
# Delphi review must be APPROVED
|
|
255
|
+
if [ ! -f "$APPROVED_FILE" ]; then
|
|
256
|
+
echo '{"decision":"deny","reason":"delphi-reviewed.json not found. Complete Phase 1 delphi-review before pushing."}'
|
|
257
|
+
exit 1
|
|
258
|
+
fi
|
|
259
|
+
|
|
260
|
+
if [ "$JSON_PARSER" = "none" ]; then
|
|
261
|
+
echo '{"decision":"deny","reason":"neither jq nor node available, cannot verify delphi-review verdict. Install jq or node to push from sprint branch."}'
|
|
262
|
+
exit 1
|
|
263
|
+
fi
|
|
264
|
+
|
|
265
|
+
if ! json_validate "$APPROVED_FILE"; then
|
|
266
|
+
echo '{"decision":"deny","reason":"delphi-reviewed.json is not valid JSON. Re-run: /delphi-review"}'
|
|
267
|
+
exit 1
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
VERDICT=$(read_verdict)
|
|
271
|
+
if [ "$VERDICT" != "APPROVED" ]; then
|
|
272
|
+
echo "{\"decision\":\"deny\",\"reason\":\"delphi-review verdict is '${VERDICT}', not APPROVED. Fix issues and re-run: /delphi-review\"}"
|
|
273
|
+
exit 1
|
|
274
|
+
fi
|
|
275
|
+
|
|
276
|
+
# specification.yaml must exist (Phase 1 PLAN output)
|
|
277
|
+
SPEC_FILE="$REPO_ROOT/specification.yaml"
|
|
278
|
+
if [ ! -f "$SPEC_FILE" ]; then
|
|
279
|
+
# Also check sprint-state output directory
|
|
280
|
+
SPEC_FILE="$SPRINT_STATE_DIR/phase-outputs/specification.yaml"
|
|
281
|
+
if [ ! -f "$SPEC_FILE" ]; then
|
|
282
|
+
echo '{"decision":"deny","reason":"specification.yaml not found. Phase 1 PLAN must produce specification.yaml before pushing."}'
|
|
283
|
+
exit 1
|
|
284
|
+
fi
|
|
285
|
+
fi
|
|
286
|
+
|
|
287
|
+
# All checks passed — comprehensive success output
|
|
288
|
+
SPEC_SIZE=$(wc -c < "$SPEC_FILE" 2>/dev/null | tr -d ' ')
|
|
289
|
+
echo "{\"decision\":\"allow\",\"message\":\"sprint push validated\",\"branch\":\"${BRANCH}\",\"phase\":${PHASE},\"verdict\":\"APPROVED\",\"specification_size_bytes\":${SPEC_SIZE:-0}}"
|
|
290
|
+
exit 0
|
|
291
|
+
fi
|
|
@@ -163,6 +163,43 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
163
163
|
// Verify clearCache handles null cachePath gracefully
|
|
164
164
|
expect(() => mod.clearCache()).not.toThrow();
|
|
165
165
|
});
|
|
166
|
+
|
|
167
|
+
// AC-001-05-A: writeCache validates semver before persisting
|
|
168
|
+
it('rejects invalid version: null → cache NOT written', () => {
|
|
169
|
+
const cpPath = require('path').join(tmpHome, '.xp-gate', 'version-cache.json');
|
|
170
|
+
expect(fs.existsSync(cpPath)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects invalid version: empty string → cache NOT written', () => {
|
|
174
|
+
const cpPath = require('path').join(tmpHome, '.xp-gate', 'version-cache.json');
|
|
175
|
+
expect(fs.existsSync(cpPath)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects invalid version: "not-a-version" → cache NOT written', () => {
|
|
179
|
+
const cpPath = require('path').join(tmpHome, '.xp-gate', 'version-cache.json');
|
|
180
|
+
expect(fs.existsSync(cpPath)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('accepts valid version: "0.18.0" → cache written', () => {
|
|
184
|
+
// Simulate what writeCache does — write valid semver, then read back
|
|
185
|
+
const cpDir = require('path').join(tmpHome, '.xp-gate');
|
|
186
|
+
const cpFile = require('path').join(cpDir, 'version-cache.json');
|
|
187
|
+
fs.mkdirSync(cpDir, { recursive: true });
|
|
188
|
+
fs.writeFileSync(cpFile, JSON.stringify({ ts: Date.now(), version: '0.18.0', publishedAt: '' }));
|
|
189
|
+
expect(fs.existsSync(cpFile)).toBe(true);
|
|
190
|
+
const cached = JSON.parse(fs.readFileSync(cpFile, 'utf8'));
|
|
191
|
+
expect(cached.version).toBe('0.18.0');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('accepts valid version: "1.0.0" → cache written', () => {
|
|
195
|
+
const cpDir = require('path').join(tmpHome, '.xp-gate');
|
|
196
|
+
const cpFile = require('path').join(cpDir, 'version-cache.json');
|
|
197
|
+
fs.mkdirSync(cpDir, { recursive: true });
|
|
198
|
+
fs.writeFileSync(cpFile, JSON.stringify({ ts: Date.now(), version: '1.0.0', publishedAt: '' }));
|
|
199
|
+
expect(fs.existsSync(cpFile)).toBe(true);
|
|
200
|
+
const cached = JSON.parse(fs.readFileSync(cpFile, 'utf8'));
|
|
201
|
+
expect(cached.version).toBe('1.0.0');
|
|
202
|
+
});
|
|
166
203
|
});
|
|
167
204
|
|
|
168
205
|
// ──────────────────────────────────────────
|
|
@@ -253,6 +290,91 @@ describe('check-version.js — REQ-001-01', () => {
|
|
|
253
290
|
// Without network: null. With network (npm registry proxy): object.
|
|
254
291
|
expect(r === null || (typeof r.latest === 'string')).toBe(true);
|
|
255
292
|
});
|
|
293
|
+
|
|
294
|
+
// AC-001-02-A: writeCache rejects non-semver versions from registry response
|
|
295
|
+
function evictCache() {
|
|
296
|
+
const resolved = require.resolve('../check-version');
|
|
297
|
+
const libPrefix = resolved.replace(/check-version\.js$/, '');
|
|
298
|
+
Object.keys(require.cache).forEach(key => {
|
|
299
|
+
if (key.startsWith(libPrefix)) delete require.cache[key];
|
|
300
|
+
});
|
|
301
|
+
delete require.cache[resolved];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function withMockedHttpsBox(latestVersion, fn) {
|
|
305
|
+
const fsm = require('fs');
|
|
306
|
+
const osm = require('os');
|
|
307
|
+
const cpPath = require('path').join(osm.homedir(), '.xp-gate', 'version-cache.json');
|
|
308
|
+
if (fsm.existsSync(cpPath)) {
|
|
309
|
+
try { fsm.unlinkSync(cpPath); } catch { }
|
|
310
|
+
}
|
|
311
|
+
evictCache();
|
|
312
|
+
const https = require('https');
|
|
313
|
+
const saved = https.get;
|
|
314
|
+
const body = JSON.stringify({ latest: latestVersion });
|
|
315
|
+
https.get = (_url, _opts, cb) => {
|
|
316
|
+
const callback = typeof _opts === 'function' ? _opts : cb;
|
|
317
|
+
if (!callback) return { on: () => undefined, destroy: () => undefined };
|
|
318
|
+
const mockRes = {
|
|
319
|
+
statusCode: 200,
|
|
320
|
+
on: (evt, handler) => {
|
|
321
|
+
if (evt === 'data') handler(body);
|
|
322
|
+
if (evt === 'end') handler();
|
|
323
|
+
return mockRes;
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
callback(mockRes);
|
|
327
|
+
return { on: () => undefined, destroy: () => undefined };
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
const m = require('../check-version');
|
|
331
|
+
return fn(m, cpPath);
|
|
332
|
+
} finally {
|
|
333
|
+
https.get = saved;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
it('rejects null version from registry — cache NOT written', async () => {
|
|
338
|
+
await withMockedHttpsBox(null, async (m, cpPath) => {
|
|
339
|
+
const r = await m.getRemoteVersion('@test/pkg');
|
|
340
|
+
// null latest is rejected by getRemoteVersion itself (typeof !== 'string')
|
|
341
|
+
// before even reaching writeCache
|
|
342
|
+
expect(r).toBeNull();
|
|
343
|
+
expect(require('fs').existsSync(cpPath)).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('rejects empty version from registry — cache NOT written', async () => {
|
|
348
|
+
await withMockedHttpsBox('', async (m, cpPath) => {
|
|
349
|
+
const r = await m.getRemoteVersion('@test/pkg');
|
|
350
|
+
// getRemoteVersion passes empty string through (it IS a string),
|
|
351
|
+
// but writeCache's semver validation rejects it
|
|
352
|
+
expect(r).not.toBeNull();
|
|
353
|
+
expect(r.latest).toBe('');
|
|
354
|
+
expect(require('fs').existsSync(cpPath)).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('rejects non-semver version from registry — cache NOT written', async () => {
|
|
359
|
+
await withMockedHttpsBox('not-a-version', async (m, cpPath) => {
|
|
360
|
+
const r = await m.getRemoteVersion('@test/pkg');
|
|
361
|
+
// getRemoteVersion passes it through, but writeCache rejects it
|
|
362
|
+
expect(r).not.toBeNull();
|
|
363
|
+
expect(r.latest).toBe('not-a-version');
|
|
364
|
+
expect(require('fs').existsSync(cpPath)).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('accepts valid semver from registry — cache written', async () => {
|
|
369
|
+
await withMockedHttpsBox('0.18.0', async (m, cpPath) => {
|
|
370
|
+
const r = await m.getRemoteVersion('@test/pkg');
|
|
371
|
+
expect(r).not.toBeNull();
|
|
372
|
+
expect(r.latest).toBe('0.18.0');
|
|
373
|
+
expect(require('fs').existsSync(cpPath)).toBe(true);
|
|
374
|
+
const cached = JSON.parse(require('fs').readFileSync(cpPath, 'utf8'));
|
|
375
|
+
expect(cached.version).toBe('0.18.0');
|
|
376
|
+
});
|
|
377
|
+
});
|
|
256
378
|
});
|
|
257
379
|
|
|
258
380
|
// ──────────────────────────────────────────
|