@boyingliu01/xp-gate 0.24.0 → 0.26.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/adapter-common.sh +51 -2
- package/adapters/python.sh +63 -5
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-incremental-typecheck.sh +104 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/lib/typecheck.sh +10 -0
- package/hooks/pre-commit +157 -33
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/init.js +46 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +61 -0
- package/lib/sprint-state-manager.js +86 -39
- package/lib/sprint-state-migrator.js +60 -22
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +167 -0
- package/mutation/__tests__/mutmut-runner.test.ts +20 -22
- package/mutation/__tests__/runners-index.test.ts +31 -3
- package/mutation/gate-m.ts +22 -4
- package/mutation/runners/gremlins-runner.ts +238 -0
- package/mutation/runners/index.ts +9 -1
- package/mutation/runners/mutmut-runner.ts +18 -12
- package/mutation/runners/types.ts +20 -0
- 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/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/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- 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/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/hooks/pre-commit
CHANGED
|
@@ -230,6 +230,16 @@ source "${AUDIT_SCRIPT_DIR}/lib/sprint-gate-report.sh"
|
|
|
230
230
|
# unit-testable without executing this hook.
|
|
231
231
|
source "${AUDIT_SCRIPT_DIR}/lib/coverage-total.sh"
|
|
232
232
|
|
|
233
|
+
# Issue #399: Python smart test selection — strategy decision + related-test
|
|
234
|
+
# lookup, kept in a library so the truth table and the path conventions are
|
|
235
|
+
# unit-testable without executing this hook.
|
|
236
|
+
source "${AUDIT_SCRIPT_DIR}/lib/python-test-selection.sh"
|
|
237
|
+
|
|
238
|
+
# Issue #534: incremental Python type-check for the pre-commit python branch —
|
|
239
|
+
# staged-only mypy (fast) that complements python.sh's CI-aligned full pass.
|
|
240
|
+
# Kept in a library so the exit-code capture and scope handling are unit-testable.
|
|
241
|
+
source "${AUDIT_SCRIPT_DIR}/lib/python-incremental-typecheck.sh"
|
|
242
|
+
|
|
233
243
|
# Read max_duration_ms from .xp-gate-config.json (default 7200000 = 2 hours)
|
|
234
244
|
_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")
|
|
235
245
|
|
|
@@ -1303,24 +1313,38 @@ else
|
|
|
1303
1313
|
fi
|
|
1304
1314
|
fi
|
|
1305
1315
|
|
|
1306
|
-
# Optional: Type checking with mypy
|
|
1316
|
+
# Optional: Type checking with mypy — INCREMENTAL (fast, staged-only).
|
|
1317
|
+
#
|
|
1318
|
+
# Responsibility boundary (#534):
|
|
1319
|
+
# * This branch (python case) runs mypy ONLY over the staged .py/.pyi
|
|
1320
|
+
# files via python_typecheck_incremental — seconds, not a full inference
|
|
1321
|
+
# pass. Uses --follow-imports=silent so a change to A.py that references
|
|
1322
|
+
# an unchanged B.py does NOT surface B's pre-existing diagnostics; only
|
|
1323
|
+
# caller-level signature errors still block. Its exit code is captured
|
|
1324
|
+
# directly (temp file), which is deterministic and does not depend on
|
|
1325
|
+
# set -o pipefail (the old `... | head | tail; rc=$?` worked only under
|
|
1326
|
+
# pipefail and could drop mypy's status when the pattern is copied into
|
|
1327
|
+
# a non-pipefail context).
|
|
1328
|
+
# * The FULL mypy pass lives OUTSIDE this hook: run_static_analysis()
|
|
1329
|
+
# (githooks/adapters/python.sh) is reachable via the adapter CLI and the
|
|
1330
|
+
# general adapter route, and CI installs mypy (quality-gates.yml) for
|
|
1331
|
+
# typed projects. NOTE: the pre-commit python case is fully inline and
|
|
1332
|
+
# never routes to run_static_analysis — so the incremental blind spot
|
|
1333
|
+
# (cross-module signature break on an unchanged file) is the accepted
|
|
1334
|
+
# trade-off here, backstopped by full mypy wherever that surface runs.
|
|
1335
|
+
# Both paths coexist: this one is the fast pre-commit pass.
|
|
1307
1336
|
if command -v mypy &> /dev/null; then
|
|
1337
|
+
# Collect staged .py/.pyi via the helper (whitespace-safe, bash-3
|
|
1338
|
+
# compatible — macOS default bash is 3.2, so avoid mapfile).
|
|
1308
1339
|
MYPY_ARGS=()
|
|
1309
1340
|
while IFS= read -r staged_file; do
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
if [ "${#MYPY_ARGS[@]}" -eq 0 ]; then
|
|
1315
|
-
MYPY_EXIT=0
|
|
1316
|
-
else
|
|
1317
|
-
echo "Running mypy type checking (optional)..."
|
|
1318
|
-
mypy --ignore-missing-imports "${MYPY_ARGS[@]}" 2>&1 | head -20 | tail -10
|
|
1319
|
-
MYPY_EXIT=$?
|
|
1320
|
-
fi
|
|
1341
|
+
MYPY_ARGS+=("$staged_file")
|
|
1342
|
+
done < <(python_staged_py_files)
|
|
1343
|
+
python_typecheck_incremental "${MYPY_ARGS[@]}"
|
|
1344
|
+
MYPY_EXIT=$?
|
|
1321
1345
|
if [ "$MYPY_EXIT" -ne 0 ]; then
|
|
1322
1346
|
echo ""
|
|
1323
|
-
echo "❌ BLOCKED -
|
|
1347
|
+
echo "❌ BLOCKED - MYPY TYPE ERRORS detected"
|
|
1324
1348
|
echo "Fix the type errors above before committing."
|
|
1325
1349
|
exit 1
|
|
1326
1350
|
fi
|
|
@@ -1337,16 +1361,32 @@ else
|
|
|
1337
1361
|
echo "ℹ️ golangci-lint not available — skipping Go linting"
|
|
1338
1362
|
echo "⏭️ SKIPPED - Code quality (golangci-lint not available)"
|
|
1339
1363
|
else
|
|
1340
|
-
|
|
1341
|
-
golangci-lint run
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
echo "
|
|
1347
|
-
|
|
1364
|
+
# #534: run golangci-lint over ONLY the staged .go files (incremental),
|
|
1365
|
+
# not the whole repo. `golangci-lint run` with no args scans every package
|
|
1366
|
+
# and dominates pre-commit time on large Go repos. golangci-lint accepts
|
|
1367
|
+
# explicit file targets. No staged .go files -> SKIP (nothing to lint).
|
|
1368
|
+
GO_FILES=$(echo "$CHANGED_FILES" | grep '\.go$' || true)
|
|
1369
|
+
if [ -z "$GO_FILES" ]; then
|
|
1370
|
+
echo "✅ PASSED - No staged Go files to lint."
|
|
1371
|
+
else
|
|
1372
|
+
echo "Running golangci-lint (incremental, staged .go)..."
|
|
1373
|
+
# #534: capture golangci-lint's REAL exit code via a temp file, not a
|
|
1374
|
+
# pipe stage's (`... | head -30; rc=$?` would take head's status; it only
|
|
1375
|
+
# works because of set -o pipefail, which is fragile and inconsistent with
|
|
1376
|
+
# the python incremental helper's temp-file pattern).
|
|
1377
|
+
GO_TMP=$(mktemp)
|
|
1378
|
+
golangci-lint run $GO_FILES > "$GO_TMP" 2>&1
|
|
1379
|
+
GOLANGCI_EXIT=$?
|
|
1380
|
+
head -30 "$GO_TMP"
|
|
1381
|
+
rm -f "$GO_TMP"
|
|
1382
|
+
if [ "$GOLANGCI_EXIT" -ne 0 ]; then
|
|
1383
|
+
echo ""
|
|
1384
|
+
echo "❌ BLOCKED - GOLANGCI-LINT ERRORS detected"
|
|
1385
|
+
echo "Fix the lint errors above before committing."
|
|
1386
|
+
exit 1
|
|
1387
|
+
fi
|
|
1388
|
+
echo "✅ PASSED - golangci-lint."
|
|
1348
1389
|
fi
|
|
1349
|
-
echo "✅ PASSED - golangci-lint."
|
|
1350
1390
|
fi
|
|
1351
1391
|
;;
|
|
1352
1392
|
|
|
@@ -2294,6 +2334,13 @@ else
|
|
|
2294
2334
|
else
|
|
2295
2335
|
echo "Running coverage check..."
|
|
2296
2336
|
|
|
2337
|
+
# Major-2 (Delphi): COV_EXIT aggregates coverage-run failures across ALL
|
|
2338
|
+
# languages in Stage 1. It starts at 0 and each language branch ORs its own
|
|
2339
|
+
# result in (`COV_EXIT=$((COV_EXIT || $?))`), so a later language's success
|
|
2340
|
+
# can never mask an earlier language's subset-run failure. Previously every
|
|
2341
|
+
# branch overwrote COV_EXIT and the last language's status won.
|
|
2342
|
+
COV_EXIT=0
|
|
2343
|
+
|
|
2297
2344
|
for CURRENT_LANG in $PROJECT_LANGS; do
|
|
2298
2345
|
ADAPTER_FILE=$(resolve_adapter_path "$CURRENT_LANG")
|
|
2299
2346
|
if [ "${CURRENT_LANG}" != "unknown" ] && [ -n "$ADAPTER_FILE" ]; then
|
|
@@ -2307,13 +2354,13 @@ else
|
|
|
2307
2354
|
# If no tests were run (TESTS_SKIPPED=true), skip coverage check entirely.
|
|
2308
2355
|
if [ "${TESTS_SKIPPED:-false}" = "true" ]; then
|
|
2309
2356
|
echo "ℹ️ Test run was skipped — skipping coverage check."
|
|
2310
|
-
COV_EXIT
|
|
2357
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2311
2358
|
elif [ -f "coverage/coverage-summary.json" ]; then
|
|
2312
2359
|
echo "TypeScript coverage collected during test run."
|
|
2313
|
-
COV_EXIT
|
|
2360
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2314
2361
|
else
|
|
2315
2362
|
echo "ℹ️ No coverage data produced — test run was skipped (no TS changes)."
|
|
2316
|
-
COV_EXIT
|
|
2363
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2317
2364
|
fi
|
|
2318
2365
|
;;
|
|
2319
2366
|
"python")
|
|
@@ -2334,12 +2381,89 @@ else
|
|
|
2334
2381
|
fi
|
|
2335
2382
|
[ -z "$PY_COV_SOURCE" ] && PY_COV_SOURCE="--cov=."
|
|
2336
2383
|
[ -z "$PY_COV_FAIL_UNDER" ] && PY_COV_FAIL_UNDER=80
|
|
2337
|
-
|
|
2338
|
-
|
|
2384
|
+
|
|
2385
|
+
# ── #399: smart test selection, the Python counterpart of #286 ──
|
|
2386
|
+
# Three tiers (lib/python-test-selection.sh holds the pure
|
|
2387
|
+
# decision logic + related-test lookup, unit-tested separately):
|
|
2388
|
+
# changed-tests / related -> run ONLY those targets, with the
|
|
2389
|
+
# repo's coverage SOURCE but WITHOUT the global fail_under
|
|
2390
|
+
# (subset coverage is meaningless globally). The run is marked
|
|
2391
|
+
# partial so Stage 2 warns instead of falsely blocking.
|
|
2392
|
+
# full -> unchanged behavior: whole suite + real threshold.
|
|
2393
|
+
# skip -> nothing test-relevant changed; no pytest at all.
|
|
2394
|
+
PY_CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
2395
|
+
| grep -E '(^|/)test_[^/]*\.py$|_test\.py$' || true)
|
|
2396
|
+
PY_CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
2397
|
+
| grep -E '\.py$' | grep -vE '(^|/)test_[^/]*\.py$|_test\.py$' || true)
|
|
2398
|
+
PY_CHANGED_ALL=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)
|
|
2399
|
+
# `grep -c '.'` prints its own zero AND exits 1 on an empty list
|
|
2400
|
+
# (#493), so the count is derived with `|| true` + wc -l instead.
|
|
2401
|
+
PY_TEST_COUNT=$(printf '%s\n' "$PY_CHANGED_TEST_FILES" | grep -c '.' || true)
|
|
2402
|
+
PY_SRC_COUNT=$(printf '%s\n' "$PY_CHANGED_SRC_FILES" | grep -c '.' || true)
|
|
2403
|
+
PY_DEPS_CHANGED=0
|
|
2404
|
+
if python_dependency_changed $PY_CHANGED_ALL; then PY_DEPS_CHANGED=1; fi
|
|
2405
|
+
PY_STRATEGY=$(select_python_test_strategy "$PY_TEST_COUNT" "$PY_SRC_COUNT" "$PY_DEPS_CHANGED")
|
|
2406
|
+
|
|
2407
|
+
# xdist parallel args (#516) — empty + warning when unavailable,
|
|
2408
|
+
# never a BLOCK; unquoted expansion is the multi-word arg list.
|
|
2409
|
+
PY_XDIST_ARGS=$(python_xdist_args 2>/dev/null || true)
|
|
2410
|
+
|
|
2411
|
+
if [ "$PY_STRATEGY" = "skip" ]; then
|
|
2412
|
+
echo "ℹ️ Python: no test/source changes — skipping test run (#399)."
|
|
2413
|
+
# Critical-1 (#399): mark the run skipped so Stage 2's
|
|
2414
|
+
# enforce_python_coverage refuses to consume a stale .coverage
|
|
2415
|
+
# left by a prior subset run. Without this flag the gate read the
|
|
2416
|
+
# leftover low-% file and BLOCKED a docs-only commit that changed
|
|
2417
|
+
# no Python. Mirrors the TS branch's TESTS_SKIPPED guard.
|
|
2418
|
+
PY_TEST_SKIPPED=true
|
|
2419
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2420
|
+
elif [ "$PY_STRATEGY" = "full" ]; then
|
|
2421
|
+
if [ "$PY_DEPS_CHANGED" = "1" ]; then
|
|
2422
|
+
echo "ℹ️ Python: dependency manifest changed — running FULL suite (#399)."
|
|
2423
|
+
elif [ "$PY_TEST_COUNT" -gt 20 ] 2>/dev/null; then
|
|
2424
|
+
echo "ℹ️ Python: $PY_TEST_COUNT changed test files (>20) — running FULL suite (#399)."
|
|
2425
|
+
fi
|
|
2426
|
+
# shellcheck disable=SC2086
|
|
2427
|
+
pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
|
|
2428
|
+
COV_EXIT=$((COV_EXIT || $?))
|
|
2429
|
+
else
|
|
2430
|
+
# changed-tests | related → resolve the exact targets.
|
|
2431
|
+
PY_TARGETS=""
|
|
2432
|
+
if [ "$PY_STRATEGY" = "changed-tests" ]; then
|
|
2433
|
+
PY_TARGETS="$PY_CHANGED_TEST_FILES"
|
|
2434
|
+
else
|
|
2435
|
+
for _py_src in $PY_CHANGED_SRC_FILES; do
|
|
2436
|
+
_py_related=$(python_related_tests "$_py_src")
|
|
2437
|
+
if [ -n "$_py_related" ]; then
|
|
2438
|
+
PY_TARGETS="${PY_TARGETS}${_py_related}
|
|
2439
|
+
"
|
|
2440
|
+
fi
|
|
2441
|
+
done
|
|
2442
|
+
PY_TARGETS=$(printf '%s\n' "$PY_TARGETS" | grep -v '^$' | sort -u)
|
|
2443
|
+
fi
|
|
2444
|
+
|
|
2445
|
+
if [ -z "$PY_TARGETS" ]; then
|
|
2446
|
+
# related lookup found nothing → tier 3 full run is safest.
|
|
2447
|
+
echo "ℹ️ Python: no related tests found for changed sources — running FULL suite (#399)."
|
|
2448
|
+
# shellcheck disable=SC2086
|
|
2449
|
+
pytest $PY_XDIST_ARGS $PY_COV_SOURCE --cov-fail-under="$PY_COV_FAIL_UNDER" --tb=short
|
|
2450
|
+
COV_EXIT=$((COV_EXIT || $?))
|
|
2451
|
+
else
|
|
2452
|
+
PY_TARGET_COUNT=$(printf '%s\n' "$PY_TARGETS" | grep -c '.' || true)
|
|
2453
|
+
echo "ℹ️ Python: selective run ($PY_STRATEGY) — $PY_TARGET_COUNT target(s):"
|
|
2454
|
+
printf '%s\n' "$PY_TARGETS" | sed 's/^/ /'
|
|
2455
|
+
# Subset run: keep --cov so data is collected, but drop the
|
|
2456
|
+
# global threshold — subset coverage is meaningless globally.
|
|
2457
|
+
PARTIAL_TEST_RUN=true
|
|
2458
|
+
# shellcheck disable=SC2086
|
|
2459
|
+
pytest $PY_XDIST_ARGS $PY_COV_SOURCE --tb=short $PY_TARGETS
|
|
2460
|
+
COV_EXIT=$((COV_EXIT || $?))
|
|
2461
|
+
fi
|
|
2462
|
+
fi
|
|
2339
2463
|
else
|
|
2340
2464
|
echo "pytest/coverage not available, running coverage via adapter..."
|
|
2341
2465
|
run_coverage_output=$(run_coverage 2>&1)
|
|
2342
|
-
COV_EXIT
|
|
2466
|
+
COV_EXIT=$((COV_EXIT || $?))
|
|
2343
2467
|
echo "$run_coverage_output" | head -30
|
|
2344
2468
|
fi
|
|
2345
2469
|
;;
|
|
@@ -2352,30 +2476,30 @@ else
|
|
|
2352
2476
|
exit 1
|
|
2353
2477
|
fi
|
|
2354
2478
|
echo "Go coverage: $TOTAL_COVERAGE%"
|
|
2355
|
-
COV_EXIT
|
|
2479
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2356
2480
|
fi
|
|
2357
2481
|
;;
|
|
2358
2482
|
"shell")
|
|
2359
2483
|
shell_cov_output=$(run_coverage 2>&1)
|
|
2360
|
-
COV_EXIT
|
|
2484
|
+
COV_EXIT=$((COV_EXIT || $?))
|
|
2361
2485
|
echo "$shell_cov_output" | head -10
|
|
2362
2486
|
echo "ℹ️ Shell coverage not typically measured"
|
|
2363
2487
|
;;
|
|
2364
2488
|
*)
|
|
2365
2489
|
default_cov_output=$(run_coverage 2>&1)
|
|
2366
|
-
COV_EXIT
|
|
2490
|
+
COV_EXIT=$((COV_EXIT || $?))
|
|
2367
2491
|
echo "$default_cov_output" | head -30
|
|
2368
2492
|
;;
|
|
2369
2493
|
esac
|
|
2370
2494
|
else
|
|
2371
2495
|
# Adapter source failed — warn and let Stage 2 attempt file-based enforcement
|
|
2372
2496
|
echo "⚠️ Could not source ${CURRENT_LANG} adapter from ${ADAPTER_FILE}, will check coverage files directly..."
|
|
2373
|
-
COV_EXIT
|
|
2497
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2374
2498
|
fi
|
|
2375
2499
|
else
|
|
2376
2500
|
# No adapter available — warn and let Stage 2 attempt file-based enforcement
|
|
2377
2501
|
echo "⚠️ No adapter found for ${CURRENT_LANG} (searched: ${ADAPTER_DIR}/, ${ADAPTER_DIR}/adapters/, ${PROJECT_GITHOOKS}/adapters/, ${SCRIPT_DIR}/adapters/) — will check coverage files directly..."
|
|
2378
|
-
COV_EXIT
|
|
2502
|
+
COV_EXIT=$((COV_EXIT || 0))
|
|
2379
2503
|
fi
|
|
2380
2504
|
done # end for CURRENT_LANG (Stage 1 coverage)
|
|
2381
2505
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent #511: Gate 8 is fail-closed since #499 (missing gitleaks BLOCKS the
|
|
3
|
+
* commit). The npm init path never surfaced that fact, so an upgrading
|
|
4
|
+
* user met a hard BLOCK with no prior signal. `probeGitleaksNotice`
|
|
5
|
+
* prints a migration notice and never installs anything.
|
|
6
|
+
* @covers probeGitleaksNotice() in lib/init.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { probeGitleaksNotice } = require('../init.js');
|
|
14
|
+
|
|
15
|
+
describe('probeGitleaksNotice (#511)', () => {
|
|
16
|
+
it('reports present and stays quiet when gitleaks is available', () => {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const result = probeGitleaksNotice(
|
|
19
|
+
() => ({ available: true, version: '8.24.2' }),
|
|
20
|
+
l => lines.push(l),
|
|
21
|
+
);
|
|
22
|
+
expect(result.available).toBe(true);
|
|
23
|
+
const joined = lines.join('\n');
|
|
24
|
+
expect(joined).toContain('gitleaks present');
|
|
25
|
+
expect(joined).toContain('8.24.2');
|
|
26
|
+
expect(joined).not.toContain('FAIL-CLOSED');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('prints the fail-closed migration notice when gitleaks is missing', () => {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const result = probeGitleaksNotice(() => ({ available: false }), l => lines.push(l));
|
|
32
|
+
expect(result.available).toBe(false);
|
|
33
|
+
const joined = lines.join('\n');
|
|
34
|
+
expect(joined).toContain('FAIL-CLOSED');
|
|
35
|
+
expect(joined).toContain('Gate 8');
|
|
36
|
+
expect(joined).toContain('install-gitleaks.sh');
|
|
37
|
+
expect(joined).toContain('brew install gitleaks');
|
|
38
|
+
expect(joined).toContain('winget install gitleaks');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('probes exactly the gitleaks tool name', () => {
|
|
42
|
+
const checkTool = vi.fn(() => ({ available: true }));
|
|
43
|
+
probeGitleaksNotice(checkTool, () => {});
|
|
44
|
+
expect(checkTool).toHaveBeenCalledWith('gitleaks');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('never installs anything — it is a pure text probe', () => {
|
|
48
|
+
// The helper must not shell out: no exec/spawn import is exercised here,
|
|
49
|
+
// and the only observable effect is log lines. A missing tool returns the
|
|
50
|
+
// notice; the caller decides whether to install.
|
|
51
|
+
const lines = [];
|
|
52
|
+
const before = process.env.PATH;
|
|
53
|
+
probeGitleaksNotice(() => ({ available: false }), l => lines.push(l));
|
|
54
|
+
expect(process.env.PATH).toBe(before);
|
|
55
|
+
expect(lines.some(l => /dev\/null|execSync|spawn/.test(l))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent #519: A real pointer-drift incident (#507) showed that a MISSED
|
|
3
|
+
* phase transition is invisible — the state file cannot distinguish
|
|
4
|
+
* "never ran" from "not yet advanced". Guards under test:
|
|
5
|
+
* (1) sequence guard: `completed` on phase n is refused unless phase
|
|
6
|
+
* n-1 is completed/skipped, with `--force` as the explicit escape;
|
|
7
|
+
* (2) before/after echo so "command didn't run" and "ran without
|
|
8
|
+
* effect" are distinguishable in the output;
|
|
9
|
+
* (3) an append-only transition audit log;
|
|
10
|
+
* (4) phase_history field normalization (`name` → `phase_name`).
|
|
11
|
+
* @covers handlePhaseTransition guard/echo, appendTransitionAudit, migrateState
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { handlePhaseTransition } from '../phase-transition.js';
|
|
19
|
+
import { SprintStateManager } from '../sprint-state-manager.js';
|
|
20
|
+
import { migrateState } from '../sprint-state-migrator.js';
|
|
21
|
+
|
|
22
|
+
function writeState(dir, state) {
|
|
23
|
+
try {
|
|
24
|
+
fs.mkdirSync(path.join(dir, '.sprint-state'), { recursive: true });
|
|
25
|
+
fs.writeFileSync(
|
|
26
|
+
path.join(dir, '.sprint-state', 'sprint-state.json'),
|
|
27
|
+
JSON.stringify(state, null, 2),
|
|
28
|
+
);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`writeState failed for ${dir}: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseState(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
_schema_version: 1,
|
|
37
|
+
id: 'sprint-519',
|
|
38
|
+
task_description: 'test',
|
|
39
|
+
phase: 2,
|
|
40
|
+
status: 'in_progress',
|
|
41
|
+
started_at: '2026-10-14T00:00:00Z',
|
|
42
|
+
phase_history: [],
|
|
43
|
+
isolation: { worktree_path: null, branch: null },
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('#519 phase-transition sequence guard', () => {
|
|
49
|
+
let dir;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519-'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('refuses `completed` on phase 3 when phase 2 is not completed/skipped', async () => {
|
|
60
|
+
writeState(dir, baseState({
|
|
61
|
+
phase: 3,
|
|
62
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
|
|
63
|
+
}));
|
|
64
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
65
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
66
|
+
const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
67
|
+
errSpy.mockRestore();
|
|
68
|
+
expect(code).toBe(1);
|
|
69
|
+
expect(stderr).toContain('Cannot complete phase 3');
|
|
70
|
+
expect(stderr).toContain('phase 2');
|
|
71
|
+
expect(stderr).toContain('phase history');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses the jump and names --force as the override', async () => {
|
|
75
|
+
writeState(dir, baseState({ phase: 3, phase_history: [] }));
|
|
76
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
77
|
+
await handlePhaseTransition(['5', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
78
|
+
const stderr = errSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
79
|
+
errSpy.mockRestore();
|
|
80
|
+
expect(stderr).toContain('--force');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('allows `completed` when the previous phase is completed', async () => {
|
|
84
|
+
writeState(dir, baseState({
|
|
85
|
+
phase: 2,
|
|
86
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'completed' }],
|
|
87
|
+
}));
|
|
88
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
89
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
90
|
+
outSpy.mockRestore();
|
|
91
|
+
expect(code).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('treats a skipped previous phase as valid', async () => {
|
|
95
|
+
writeState(dir, baseState({
|
|
96
|
+
phase: 2,
|
|
97
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'skipped' }],
|
|
98
|
+
}));
|
|
99
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
100
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
101
|
+
outSpy.mockRestore();
|
|
102
|
+
expect(code).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('--force overrides the guard and warns', async () => {
|
|
106
|
+
writeState(dir, baseState({ phase: 3, phase_history: [] }));
|
|
107
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
108
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
109
|
+
const code = await handlePhaseTransition(['3', 'completed', '--dir', dir, '--skip-evidence', 'x', '--force']);
|
|
110
|
+
const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
111
|
+
outSpy.mockRestore();
|
|
112
|
+
warnSpy.mockRestore();
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
expect(warned).toContain('overridden');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not guard phase 1 (no predecessor)', async () => {
|
|
118
|
+
writeState(dir, baseState({ phase: 1, phase_history: [] }));
|
|
119
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
120
|
+
const code = await handlePhaseTransition(['1', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
121
|
+
outSpy.mockRestore();
|
|
122
|
+
expect(code).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not guard non-completed statuses', async () => {
|
|
126
|
+
writeState(dir, baseState({ phase: 3, phase_history: [] }));
|
|
127
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
128
|
+
const code = await handlePhaseTransition(['3', 'in_progress', '--dir', dir]);
|
|
129
|
+
outSpy.mockRestore();
|
|
130
|
+
expect(code).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('#519 before/after echo', () => {
|
|
135
|
+
let dir;
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519echo-'));
|
|
138
|
+
});
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('prints before→after for the top-level phase/status and the entry', async () => {
|
|
144
|
+
// Phase 1 must be completed for the phase-2 completion to pass the
|
|
145
|
+
// sequence guard (#519) — otherwise the echo is never reached.
|
|
146
|
+
writeState(dir, baseState({
|
|
147
|
+
phase: 2,
|
|
148
|
+
status: 'in_progress',
|
|
149
|
+
phase_history: [
|
|
150
|
+
{ phase: 1, name: 'PREP', status: 'completed' },
|
|
151
|
+
{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' },
|
|
152
|
+
],
|
|
153
|
+
}));
|
|
154
|
+
const outSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
155
|
+
const code = await handlePhaseTransition(['2', 'completed', '--dir', dir, '--skip-evidence', 'x']);
|
|
156
|
+
const output = outSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
157
|
+
outSpy.mockRestore();
|
|
158
|
+
expect(code).toBe(0);
|
|
159
|
+
expect(output).toContain('before→after');
|
|
160
|
+
expect(output).toContain('in_progress → completed');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('#519 transition audit log + field normalization', () => {
|
|
165
|
+
let dir;
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p519audit-'));
|
|
168
|
+
});
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('appends one JSON line per transition with from/to', () => {
|
|
174
|
+
writeState(dir, baseState({ phase: 2, phase_history: [] }));
|
|
175
|
+
const manager = new SprintStateManager(dir);
|
|
176
|
+
manager.transitionPhase(2, 'in_progress');
|
|
177
|
+
manager.transitionPhase(2, 'completed');
|
|
178
|
+
const auditFile = path.join(dir, '.sprint-state', 'transition-audit.jsonl');
|
|
179
|
+
expect(fs.existsSync(auditFile)).toBe(true);
|
|
180
|
+
const lines = fs.readFileSync(auditFile, 'utf8').trim().split('\n');
|
|
181
|
+
expect(lines.length).toBe(2);
|
|
182
|
+
const first = JSON.parse(lines[0]);
|
|
183
|
+
expect(first.phase).toBe(2);
|
|
184
|
+
expect(first.phase_name).toBe('DESIGN');
|
|
185
|
+
expect(first.to_status).toBe('in_progress');
|
|
186
|
+
expect(first.timestamp).toBeTruthy();
|
|
187
|
+
const second = JSON.parse(lines[1]);
|
|
188
|
+
expect(second.to_status).toBe('completed');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('audit is best-effort: a write failure does not break the transition', () => {
|
|
192
|
+
writeState(dir, baseState({ phase: 2, phase_history: [] }));
|
|
193
|
+
const manager = new SprintStateManager(dir);
|
|
194
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
195
|
+
// Break the real appendFileSync so appendTransitionAudit's OWN try/catch is
|
|
196
|
+
// exercised (mocking the method itself would bypass the production guard).
|
|
197
|
+
const appendSpy = vi.spyOn(fs, 'appendFileSync').mockImplementation(() => {
|
|
198
|
+
throw new Error('disk full');
|
|
199
|
+
});
|
|
200
|
+
expect(() => manager.transitionPhase(2, 'in_progress')).not.toThrow();
|
|
201
|
+
const warned = warnSpy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
202
|
+
appendSpy.mockRestore();
|
|
203
|
+
warnSpy.mockRestore();
|
|
204
|
+
expect(warned).toContain('transition audit write failed');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('migrateState normalizes legacy `name` into `phase_name`', () => {
|
|
208
|
+
// A legacy file (no _schema_version) whose phase 1 entry carries `name`
|
|
209
|
+
// rather than `phase_name` — the exact shape #507 left behind.
|
|
210
|
+
const legacy = baseState({
|
|
211
|
+
_schema_version: undefined,
|
|
212
|
+
phase_history: [{ phase: 1, name: 'PREP', status: 'completed' }],
|
|
213
|
+
});
|
|
214
|
+
const migrated = migrateState(legacy, dir);
|
|
215
|
+
const entry = migrated.phase_history[0];
|
|
216
|
+
// The canonical field is populated (via the legacy remap and/or the
|
|
217
|
+
// name→phase_name normalization) and the legacy field is preserved.
|
|
218
|
+
expect(entry.phase_name).toBeTruthy();
|
|
219
|
+
expect(entry.name).toBe('PREP');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('migrateState backfills `name` from `phase_name` (both directions)', () => {
|
|
223
|
+
const legacy = baseState({
|
|
224
|
+
_schema_version: undefined,
|
|
225
|
+
phase_history: [{ phase: 2, phase_name: 'DESIGN', status: 'in_progress' }],
|
|
226
|
+
});
|
|
227
|
+
const migrated = migrateState(legacy, dir);
|
|
228
|
+
const entry = migrated.phase_history[0];
|
|
229
|
+
expect(entry.phase_name).toBeTruthy();
|
|
230
|
+
// `name` is backfilled so a reader consulting either field agrees.
|
|
231
|
+
expect(entry.name).toBe(entry.phase_name);
|
|
232
|
+
});
|
|
233
|
+
});
|
package/lib/init.js
CHANGED
|
@@ -271,6 +271,46 @@ function printCliToolStatus() {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Probe for gitleaks and warn when it is missing (#511).
|
|
276
|
+
*
|
|
277
|
+
* Gate 8 became fail-closed in #499: a missing gitleaks BLOCKs the commit
|
|
278
|
+
* outright (it does NOT skip, unlike the tools reported by
|
|
279
|
+
* printCliToolStatus). The user-facing installers never provisioned it, so an
|
|
280
|
+
* existing user upgrading into #499 hits a hard BLOCK on their next commit with
|
|
281
|
+
* no prior signal — this is the missing migration notice.
|
|
282
|
+
*
|
|
283
|
+
* Probe-and-prompt only: nothing is installed here (installing is the user's
|
|
284
|
+
* explicit choice, via the printed command). The fail-closed semantics of
|
|
285
|
+
* #499 are untouched.
|
|
286
|
+
*
|
|
287
|
+
* @param {(name: string) => {available: boolean, version?: string}} checkTool
|
|
288
|
+
* @param {(line: string) => void} [log]
|
|
289
|
+
* @returns {{available: boolean}} the probe result, for callers that branch on it
|
|
290
|
+
*/
|
|
291
|
+
function probeGitleaksNotice(checkTool, log = console.log) {
|
|
292
|
+
const result = checkTool('gitleaks');
|
|
293
|
+
if (result.available) {
|
|
294
|
+
log(`✓ gitleaks present${result.version ? `: ${result.version}` : ''}`);
|
|
295
|
+
return { available: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
log('');
|
|
299
|
+
log('⚠ gitleaks is NOT installed.');
|
|
300
|
+
log(' Gate 8 (secret scanning) is FAIL-CLOSED since #499: your next commit');
|
|
301
|
+
log(' will be BLOCKED until gitleaks is available.');
|
|
302
|
+
log('');
|
|
303
|
+
log(' Install it now:');
|
|
304
|
+
log(' bash scripts/install-gitleaks.sh');
|
|
305
|
+
log('');
|
|
306
|
+
log(' Per-OS alternatives:');
|
|
307
|
+
log(' macOS: brew install gitleaks');
|
|
308
|
+
log(' Linux: https://github.com/gitleaks/gitleaks/releases');
|
|
309
|
+
log(' Windows: winget install gitleaks (or: scoop install gitleaks)');
|
|
310
|
+
log('');
|
|
311
|
+
return { available: false };
|
|
312
|
+
}
|
|
313
|
+
|
|
274
314
|
/**
|
|
275
315
|
* Ask the user for confirmation via stdin.
|
|
276
316
|
* @param {string} question - The yes/no question to display
|
|
@@ -352,6 +392,11 @@ async function init(args) {
|
|
|
352
392
|
console.log('Checking CLI tools...');
|
|
353
393
|
printCliToolStatus();
|
|
354
394
|
|
|
395
|
+
// #511: gitleaks is the one tool whose absence BLOCKS rather than SKIPs
|
|
396
|
+
// (Gate 8 fail-closed, #499) — surface it separately so an upgrading user
|
|
397
|
+
// has a migration path before the first blocked commit.
|
|
398
|
+
probeGitleaksNotice(checkCliTool);
|
|
399
|
+
|
|
355
400
|
console.log('Checking platform dependencies...');
|
|
356
401
|
// Detect platform and check/auto-install dependencies
|
|
357
402
|
const platform = detectPlatform();
|
|
@@ -925,4 +970,4 @@ function injectKarpathyPrinciples(projectRoot) {
|
|
|
925
970
|
// setupGlobal is exported so the global-install wiring (agents deployed to the
|
|
926
971
|
// real HOME) can be audited under a sandboxed HOME, not just asserted to exist
|
|
927
972
|
// — the historical miss was "local deployed, global did not" (#417, #429).
|
|
928
|
-
module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal };
|
|
973
|
+
module.exports = { init, promptBootstrap, installModuleRuntimeDeps, deployQoderDelphiAgents, configureQoderDelphiAgents, readQoderModelId, setupGlobal, probeGitleaksNotice };
|