@boyingliu01/xp-gate 0.35.6 → 0.35.8
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 +60 -0
- package/hooks/lib/assertion-quality.sh +47 -0
- package/hooks/pre-commit +131 -21
- package/lib/__tests__/assertion-debt.test.js +99 -0
- package/lib/__tests__/gate-rules.test.js +58 -0
- package/lib/__tests__/mirror-manifest.test.js +64 -0
- package/lib/assertion-debt.js +173 -0
- package/lib/gate-rules.js +149 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/mirror-manifest.js +112 -0
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +14 -1
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +14 -1
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +14 -1
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/sprint-flow/references/phase-3-build.md +14 -1
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js
CHANGED
|
@@ -365,6 +365,21 @@ const COMMANDS = {
|
|
|
365
365
|
},
|
|
366
366
|
usage: 'xp-gate gate-coverage [--json] [--debt] [--strict] [--branch <name>]'
|
|
367
367
|
},
|
|
368
|
+
'gate-rules': {
|
|
369
|
+
description: 'Print the gate limits that can block a commit, generated from the files that define them (#652)',
|
|
370
|
+
run: subargs => {
|
|
371
|
+
const { collectGateRules, renderGateRules } = require('../lib/gate-rules.js');
|
|
372
|
+
const rules = collectGateRules({ cwd: process.cwd() });
|
|
373
|
+
if (subargs.includes('--json')) {
|
|
374
|
+
process.stdout.write(`${JSON.stringify(rules, null, 2)}\n`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Generated, never hand-copied: a checklist that drifts from the config it
|
|
378
|
+
// describes teaches the wrong rule and then contradicts it at commit time.
|
|
379
|
+
process.stdout.write(`${renderGateRules(rules)}\n`);
|
|
380
|
+
},
|
|
381
|
+
usage: 'xp-gate gate-rules [--json]'
|
|
382
|
+
},
|
|
368
383
|
'rework': {
|
|
369
384
|
description: 'Report rework: first-pass rate, rounds before landing, top blocking reasons (#651)',
|
|
370
385
|
run: subargs => {
|
|
@@ -673,15 +688,60 @@ function handleAudit(args) {
|
|
|
673
688
|
return;
|
|
674
689
|
}
|
|
675
690
|
|
|
691
|
+
// #668: pre-existing assertion debt, so it can be cleared in batches instead of
|
|
692
|
+
// being discovered one unrelated commit at a time.
|
|
693
|
+
if (args[0] === 'assertions') {
|
|
694
|
+
handleAuditAssertions(args);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
676
698
|
console.error('Error: Unknown audit subcommand or missing flag');
|
|
677
699
|
console.error('Usage:');
|
|
678
700
|
console.error(' xp-gate audit record --gate-id X --gate-name Y --passed true/false --issues-found N --duration-ms N --trigger commit');
|
|
679
701
|
console.error(' xp-gate audit --tail [N] (default: 20)');
|
|
680
702
|
console.error(' xp-gate audit --stats (per-gate stats + anomaly summary)');
|
|
681
703
|
console.error(' xp-gate audit --check (exit 1 if the ledger has anomalies)');
|
|
704
|
+
console.error(' xp-gate audit assertions (list pre-existing assertion debt across the repo; --json)');
|
|
682
705
|
process.exit(1);
|
|
683
706
|
}
|
|
684
707
|
|
|
708
|
+
/**
|
|
709
|
+
* `xp-gate audit assertions` (#668) — list pre-existing vacuous assertions.
|
|
710
|
+
*
|
|
711
|
+
* Gate 5d only inspects files in the diff, so standing debt surfaces during
|
|
712
|
+
* unrelated commits and reads as if that change introduced it. This makes the debt
|
|
713
|
+
* a work queue instead of a series of false accusations.
|
|
714
|
+
*
|
|
715
|
+
* Advisory: always exits 0. A debt list that fails builds becomes a number people
|
|
716
|
+
* learn to ignore.
|
|
717
|
+
*/
|
|
718
|
+
function handleAuditAssertions(args) {
|
|
719
|
+
const { scanForAssertionDebt, summarizeDebt } = require(path.join(__dirname, '..', 'lib', 'assertion-debt.js'));
|
|
720
|
+
const results = scanForAssertionDebt({ cwd: process.cwd() });
|
|
721
|
+
const summary = summarizeDebt(results);
|
|
722
|
+
if (args.includes('--json')) {
|
|
723
|
+
process.stdout.write(`${JSON.stringify({ summary, files: results }, null, 2)}\n`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (summary.total === 0) {
|
|
727
|
+
console.log('✅ No pre-existing assertion debt found.');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
console.log(`Found ${summary.total} pre-existing assertion issue(s) in ${summary.files} file(s):`);
|
|
731
|
+
console.log(` tautological ${summary.byKind.tautological}`);
|
|
732
|
+
console.log(` swallowed-error ${summary.byKind['swallowed-error']}`);
|
|
733
|
+
console.log('');
|
|
734
|
+
for (const entry of results) {
|
|
735
|
+
console.log(`${entry.file} (${entry.findings.length})`);
|
|
736
|
+
for (const finding of entry.findings) {
|
|
737
|
+
console.log(` ${finding.line}: [${finding.kind}] ${finding.text}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
console.log('');
|
|
741
|
+
console.log('These predate the current diff and would be reported as "introduced by this change"');
|
|
742
|
+
console.log('if the file is touched. See skills/test-driven-development/testing-anti-patterns.md');
|
|
743
|
+
}
|
|
744
|
+
|
|
685
745
|
function printAuditTable(entries) {
|
|
686
746
|
console.log('');
|
|
687
747
|
console.log('Recent Audit Entries:');
|
|
@@ -178,6 +178,53 @@ classify_swallowed_errors() {
|
|
|
178
178
|
grep -nE 'catch([[:space:]]*\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' || true
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
# #668: is the flagged line part of THIS change, or merely carried in by the file?
|
|
182
|
+
#
|
|
183
|
+
# Gate 5d runs over whole files, so a file that was edited anywhere gets all of its
|
|
184
|
+
# assertions re-examined. Pre-existing debt then blocks an unrelated commit and
|
|
185
|
+
# reads as "this change introduced it". The author who fixed `writeReport()`'s error
|
|
186
|
+
# handling was stopped by four `expect(true).toBe(true)` lines that had been there
|
|
187
|
+
# for months.
|
|
188
|
+
#
|
|
189
|
+
# The cost is not the one false block. It is that the noise teaches people to add
|
|
190
|
+
# `@assertion-justified` rather than fix the test -- a gate that trains people to
|
|
191
|
+
# annotate past it is worse than no gate.
|
|
192
|
+
#
|
|
193
|
+
# Args: $1 = file path, $2 = line number, $3 = added-line ranges ("start-end start-end")
|
|
194
|
+
# Returns 0 when the line is inside an added hunk (i.e. genuinely introduced here).
|
|
195
|
+
assertion_line_is_new() {
|
|
196
|
+
local _aq_file="$1" _aq_line="$2" _aq_ranges="$3"
|
|
197
|
+
local _aq_range _aq_start _aq_end
|
|
198
|
+
for _aq_range in $_aq_ranges; do
|
|
199
|
+
_aq_start="${_aq_range%%-*}"
|
|
200
|
+
_aq_end="${_aq_range##*-}"
|
|
201
|
+
if [ "$_aq_line" -ge "$_aq_start" ] 2>/dev/null && [ "$_aq_line" -le "$_aq_end" ] 2>/dev/null; then
|
|
202
|
+
return 0
|
|
203
|
+
fi
|
|
204
|
+
done
|
|
205
|
+
return 1
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Emit "start-end" for each added hunk in the staged diff of $1.
|
|
209
|
+
# Empty output means the file has no added lines at all, in which case every finding
|
|
210
|
+
# is pre-existing by construction.
|
|
211
|
+
staged_added_line_ranges() {
|
|
212
|
+
local _aq_file="$1"
|
|
213
|
+
git diff --cached -U0 -- "$_aq_file" 2>/dev/null \
|
|
214
|
+
| awk '
|
|
215
|
+
/^@@/ {
|
|
216
|
+
# @@ -a,b +c,d @@ -- the +side is the new-file line numbering.
|
|
217
|
+
if (match($0, /\+[0-9]+(,[0-9]+)?/)) {
|
|
218
|
+
spec = substr($0, RSTART + 1, RLENGTH - 1)
|
|
219
|
+
n = split(spec, parts, ",")
|
|
220
|
+
start = parts[1] + 0
|
|
221
|
+
count = (n > 1) ? parts[2] + 0 : 1
|
|
222
|
+
if (count > 0) printf "%d-%d ", start, start + count - 1
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
' || true
|
|
226
|
+
}
|
|
227
|
+
|
|
181
228
|
# Anti-patterns 3-5 (WARNING tier): weak-only assertions, mock-echo assertions, and
|
|
182
229
|
# call-order over-verification. These can be legitimate, so each is reported but
|
|
183
230
|
# never blocks.
|
package/hooks/pre-commit
CHANGED
|
@@ -342,7 +342,9 @@ file_exists_in_subdir() {
|
|
|
342
342
|
# in every gate, so their definitions lead the first call.
|
|
343
343
|
# ============================================================================
|
|
344
344
|
AUDIT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
345
|
-
|
|
345
|
+
# PROJECT_ROOT is resolved once above (line ~235); re-running `git rev-parse` here
|
|
346
|
+
# costs another ~90ms fork on every commit for the same answer (#640).
|
|
347
|
+
PROJECT_ROOT_FOR_AUDIT="${PROJECT_ROOT:-$(cd "$AUDIT_SCRIPT_DIR/../.." 2>/dev/null && pwd)}"
|
|
346
348
|
|
|
347
349
|
# Audit attribution safety net: if a previous commit's post-commit never fired
|
|
348
350
|
# (hooks skipped, crash, or an earlier --no-verify), its staged entries are
|
|
@@ -759,6 +761,10 @@ if [ "$is_protected" = "true" ]; then
|
|
|
759
761
|
else
|
|
760
762
|
# Check if staged changes include VERSION or CHANGELOG.md
|
|
761
763
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
|
|
764
|
+
# #640: a fork costs ~90ms on Windows (measured: 100 `git rev-parse` = 9456ms),
|
|
765
|
+
# and this exact query is re-run by later gates. Cache it once; consumers below
|
|
766
|
+
# read $XP_STAGED_ACM. Subprocess COUNT, not scan duration, is the bottleneck.
|
|
767
|
+
XP_STAGED_ACM="$STAGED_FILES"
|
|
762
768
|
for file in $STAGED_FILES; do
|
|
763
769
|
case "$file" in
|
|
764
770
|
VERSION) has_version_change=true ;;
|
|
@@ -918,7 +924,7 @@ echo ""
|
|
|
918
924
|
# scope rationale as the mirror exclusion (#507 Delphi round-2 follow-up).
|
|
919
925
|
MIRROR_PATH_FILTER='^src/npm-package/'
|
|
920
926
|
DASHBOARD_PATH_FILTER='^dashboard/'
|
|
921
|
-
RAW_CHANGED_FILES
|
|
927
|
+
RAW_CHANGED_FILES="${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM)}"
|
|
922
928
|
|
|
923
929
|
# Dependencies and build output are not project code (#649).
|
|
924
930
|
#
|
|
@@ -2345,7 +2351,7 @@ else
|
|
|
2345
2351
|
GATE_5_SKIP_COUNT=0
|
|
2346
2352
|
GATE_5_DELETED_TESTS=""
|
|
2347
2353
|
if declare -f classify_test_focus >/dev/null 2>&1; then
|
|
2348
|
-
STAGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
2354
|
+
STAGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
|
|
2349
2355
|
| grep -E '\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|cs|scala|ps1)$|(^|/)(test_[^/]*\.py|[^/]*_test\.(py|go))$' || true)
|
|
2350
2356
|
if [ -n "$STAGED_TEST_FILES" ]; then
|
|
2351
2357
|
while IFS= read -r _tf; do
|
|
@@ -2395,17 +2401,63 @@ else
|
|
|
2395
2401
|
_tauto=$(printf '%s\n' "$_tf_content" | classify_tautological_assertions || true)
|
|
2396
2402
|
_swallow=$(printf '%s\n' "$_tf_content" | classify_swallowed_errors || true)
|
|
2397
2403
|
_weak=$(printf '%s\n' "$_tf_content" | classify_weak_assertions || true)
|
|
2404
|
+
# #668: split the findings into "added by this change" and
|
|
2405
|
+
# "pre-existing". Gate 5d examines whole files, so editing a file
|
|
2406
|
+
# anywhere re-examines all of its assertions; presenting standing debt
|
|
2407
|
+
# under an unrelated commit is a false attribution, and false
|
|
2408
|
+
# attribution is what drives people to @assertion-justified instead of
|
|
2409
|
+
# a real fix.
|
|
2410
|
+
_new_ranges=$(staged_added_line_ranges "$_tf" 2>/dev/null || true)
|
|
2398
2411
|
if [ -n "$_tauto" ]; then
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2412
|
+
_tauto_new=""
|
|
2413
|
+
_tauto_old=""
|
|
2414
|
+
while IFS= read -r _tline; do
|
|
2415
|
+
[ -n "$_tline" ] || continue
|
|
2416
|
+
_tnum="${_tline%%:*}"
|
|
2417
|
+
if assertion_line_is_new "$_tf" "$_tnum" "$_new_ranges"; then
|
|
2418
|
+
_tauto_new="${_tauto_new}${_tline}"$'\n'
|
|
2419
|
+
else
|
|
2420
|
+
_tauto_old="${_tauto_old}${_tline}"$'\n'
|
|
2421
|
+
fi
|
|
2422
|
+
done <<< "$_tauto"
|
|
2423
|
+
if [ -n "$_tauto_new" ]; then
|
|
2424
|
+
echo "❌ BLOCKED - tautological assertion(s) added by this change in $_tf (cannot fail):"
|
|
2425
|
+
printf '%s' "$_tauto_new" | sed 's/^/ /'
|
|
2426
|
+
echo " See skills/test-driven-development/testing-anti-patterns.md"
|
|
2427
|
+
GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
|
|
2428
|
+
fi
|
|
2429
|
+
if [ -n "$_tauto_old" ]; then
|
|
2430
|
+
echo "⚠️ WARN - PRE-EXISTING tautological assertion(s) in $_tf (NOT introduced by this change):"
|
|
2431
|
+
printf '%s' "$_tauto_old" | sed 's/^/ /'
|
|
2432
|
+
echo " These predate this commit. Two options:"
|
|
2433
|
+
echo " fix them now (see 'xp-gate audit assertions'), or"
|
|
2434
|
+
echo " leave them - they are not blocking you."
|
|
2435
|
+
fi
|
|
2403
2436
|
fi
|
|
2404
2437
|
if [ -n "$_swallow" ]; then
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2438
|
+
# Same #668 split as the tautology branch above.
|
|
2439
|
+
_sw_new=""
|
|
2440
|
+
_sw_old=""
|
|
2441
|
+
while IFS= read -r _sline; do
|
|
2442
|
+
[ -n "$_sline" ] || continue
|
|
2443
|
+
_snum="${_sline%%:*}"
|
|
2444
|
+
if assertion_line_is_new "$_tf" "$_snum" "$_new_ranges"; then
|
|
2445
|
+
_sw_new="${_sw_new}${_sline}"$'\n'
|
|
2446
|
+
else
|
|
2447
|
+
_sw_old="${_sw_old}${_sline}"$'\n'
|
|
2448
|
+
fi
|
|
2449
|
+
done <<< "$_swallow"
|
|
2450
|
+
if [ -n "$_sw_new" ]; then
|
|
2451
|
+
echo "❌ BLOCKED - empty catch added by this change in $_tf swallows the failure it guards:"
|
|
2452
|
+
printf '%s' "$_sw_new" | sed 's/^/ /'
|
|
2453
|
+
echo " See skills/test-driven-development/testing-anti-patterns.md"
|
|
2454
|
+
GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
|
|
2455
|
+
fi
|
|
2456
|
+
if [ -n "$_sw_old" ]; then
|
|
2457
|
+
echo "⚠️ WARN - PRE-EXISTING empty catch(es) in $_tf (NOT introduced by this change):"
|
|
2458
|
+
printf '%s' "$_sw_old" | sed 's/^/ /'
|
|
2459
|
+
echo " These predate this commit; see 'xp-gate audit assertions'."
|
|
2460
|
+
fi
|
|
2409
2461
|
fi
|
|
2410
2462
|
if [ -n "$_weak" ]; then
|
|
2411
2463
|
echo "⚠️ WARN - test block(s) in $_tf assert only weak properties:"
|
|
@@ -2705,7 +2757,7 @@ else
|
|
|
2705
2757
|
# Gate 5b: Mock Density ADVISORY Scan (pure bash — no npx tsx overhead)
|
|
2706
2758
|
# 30% = ADVISORY, 50% = suggests @mock-justified. Does NOT block commit.
|
|
2707
2759
|
# ========================================================================
|
|
2708
|
-
CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM | filter_project_paths | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
|
|
2760
|
+
CHANGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM)}" | filter_project_paths | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|cjs|mjs|py|go)$' || true)
|
|
2709
2761
|
|
|
2710
2762
|
# Nested plugin sub-projects (plugins/<name>/) own their own vitest config and
|
|
2711
2763
|
# are excluded from the root suite (`vitest.config.ts` excludes `plugins/**`,
|
|
@@ -2853,7 +2905,7 @@ else
|
|
|
2853
2905
|
CHANGED_TEST_FILE_COUNT=$(echo "$SELECTED_TEST_FILES" | grep -c '.' || true)
|
|
2854
2906
|
|
|
2855
2907
|
# Also get changed source files (non-test) for related-test lookup
|
|
2856
|
-
CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM | filter_project_paths | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
|
|
2908
|
+
CHANGED_SRC_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM)}" | filter_project_paths | grep -E '\.(ts|tsx|js|jsx)$' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__tests__' | grep -v '\.d\.ts$' | grep -v '__snapshots__' || true)
|
|
2857
2909
|
# Nested plugin sub-projects are excluded from the root suite; their
|
|
2858
2910
|
# sources must not drive root-vitest related-test lookup either (it
|
|
2859
2911
|
# would resolve to plugin-local paths vitest cannot collect).
|
|
@@ -3088,16 +3140,16 @@ else
|
|
|
3088
3140
|
# partial so Stage 2 warns instead of falsely blocking.
|
|
3089
3141
|
# full -> unchanged behavior: whole suite + real threshold.
|
|
3090
3142
|
# skip -> nothing test-relevant changed; no pytest at all.
|
|
3091
|
-
PY_CHANGED_TEST_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
3143
|
+
PY_CHANGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
|
|
3092
3144
|
| filter_project_paths \
|
|
3093
3145
|
| grep -E '(^|/)test_[^/]*\.py$|_test\.py$' || true)
|
|
3094
|
-
PY_CHANGED_SRC_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null \
|
|
3146
|
+
PY_CHANGED_SRC_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
|
|
3095
3147
|
| filter_project_paths \
|
|
3096
3148
|
| grep -E '\.py$' | grep -vE '(^|/)test_[^/]*\.py$|_test\.py$' || true)
|
|
3097
3149
|
# NOT filtered on purpose: this list answers "did a requirements
|
|
3098
3150
|
# file change", which is a property of the whole commit including
|
|
3099
3151
|
# paths under a vendor/ tree that filter_project_paths removes.
|
|
3100
|
-
PY_CHANGED_ALL
|
|
3152
|
+
PY_CHANGED_ALL="${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)}"
|
|
3101
3153
|
# `grep -c '.'` prints its own zero AND exits 1 on an empty list
|
|
3102
3154
|
# (#493), so the count is derived with `|| true` + wc -l instead.
|
|
3103
3155
|
PY_TEST_COUNT=$(printf '%s\n' "$PY_CHANGED_TEST_FILES" | grep -c '.' || true)
|
|
@@ -3844,14 +3896,21 @@ else
|
|
|
3844
3896
|
exit 1
|
|
3845
3897
|
fi
|
|
3846
3898
|
echo " Running archlint for TypeScript..."
|
|
3847
|
-
#
|
|
3848
|
-
#
|
|
3849
|
-
|
|
3899
|
+
# #640: archlint costs ~8s and scans the whole tree, but an architecture
|
|
3900
|
+
# violation cannot be introduced by a commit that touches no source file.
|
|
3901
|
+
# Gating on "did any source change" removes the single largest fixed cost
|
|
3902
|
+
# from docs/config-only commits without weakening the check at all.
|
|
3903
|
+
if ! printf '%s\n' "${XP_STAGED_ACM:-}" | grep -qE '\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|kts|rs|cpp|cc|cxx|c|h|hpp|swift|dart)$'; then
|
|
3904
|
+
echo " ⏭️ SKIPPED - Architecture validation (no source files changed)"
|
|
3905
|
+
ARCHLINT_OUTPUT=""
|
|
3906
|
+
ARCHLINT_EXIT=0
|
|
3907
|
+
elif [ -f "$PROJECT_ROOT/.architecture-baseline.json" ]; then
|
|
3850
3908
|
ARCHLINT_OUTPUT=$(run_archlint diff .architecture-baseline.json --fail-on high 2>&1)
|
|
3909
|
+
ARCHLINT_EXIT=$?
|
|
3851
3910
|
else
|
|
3852
3911
|
ARCHLINT_OUTPUT=$(run_archlint scan . -f table --quiet 2>&1)
|
|
3912
|
+
ARCHLINT_EXIT=$?
|
|
3853
3913
|
fi
|
|
3854
|
-
ARCHLINT_EXIT=$?
|
|
3855
3914
|
echo "$ARCHLINT_OUTPUT" | tail -30
|
|
3856
3915
|
if [ "$ARCHLINT_EXIT" -ne 0 ]; then
|
|
3857
3916
|
echo ""
|
|
@@ -4560,7 +4619,58 @@ fi
|
|
|
4560
4619
|
# ============================================================================
|
|
4561
4620
|
|
|
4562
4621
|
# ============================================================================
|
|
4563
|
-
# GATE
|
|
4622
|
+
# GATE 12b: Mirror Parity (#654)
|
|
4623
|
+
#
|
|
4624
|
+
# `githooks/**` and `plugins/*/src/**` are canonical; `src/npm-package/**` holds
|
|
4625
|
+
# byte-identical mirrors that get published. Editing only the mirror means the next
|
|
4626
|
+
# prepack silently regenerates it over the change -- v0.27.3 shipped exactly that
|
|
4627
|
+
# bug, and mirror/sync rework accounts for 58 failures in the session logs.
|
|
4628
|
+
#
|
|
4629
|
+
# The check used to run only at pre-push, which turns a two-second fix into a
|
|
4630
|
+
# five-minute wait. It runs here now, but ONLY over files in this commit: a
|
|
4631
|
+
# full-tree scan on every commit is the cost that pushed it to pre-push in the
|
|
4632
|
+
# first place.
|
|
4633
|
+
# ============================================================================
|
|
4634
|
+
MIRROR_STAGED=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)
|
|
4635
|
+
if [ -n "$MIRROR_STAGED" ] && printf '%s\n' "$MIRROR_STAGED" | grep -qE '^(githooks/|plugins/[^/]+/src/)'; then
|
|
4636
|
+
# Reuse PROJECT_ROOT: `git rev-parse` costs ~90ms per fork on Windows and this
|
|
4637
|
+
# gate must not add two of them to every commit (#640).
|
|
4638
|
+
MIRROR_REPO_ROOT="${PROJECT_ROOT:-}"
|
|
4639
|
+
if [ -n "$MIRROR_REPO_ROOT" ] && [ -f "$MIRROR_REPO_ROOT/scripts/check-hook-mirror.sh" ]; then
|
|
4640
|
+
# Scope to the touched families only: a mirror pair is clean or dirty
|
|
4641
|
+
# independently, and checking untouched families is the slow path this gate
|
|
4642
|
+
# exists to avoid.
|
|
4643
|
+
MIRROR_FAIL=0
|
|
4644
|
+
while IFS= read -r _mf; do
|
|
4645
|
+
[ -n "$_mf" ] || continue
|
|
4646
|
+
case "$_mf" in
|
|
4647
|
+
githooks/*) _mm="src/npm-package/hooks/${_mf#githooks/}" ;;
|
|
4648
|
+
plugins/*/src/*) _mm=$(printf '%s' "$_mf" | sed 's|^plugins/\([^/]*\)/src/|src/npm-package/plugins/\1/src/|') ;;
|
|
4649
|
+
*) continue ;;
|
|
4650
|
+
esac
|
|
4651
|
+
if [ ! -f "$MIRROR_REPO_ROOT/$_mm" ]; then
|
|
4652
|
+
echo "❌ BLOCKED - Gate 12b: $_mf has no mirror at $_mm"
|
|
4653
|
+
echo " Run: node src/npm-package/scripts/sync-package-content.js --fix"
|
|
4654
|
+
MIRROR_FAIL=$((MIRROR_FAIL + 1))
|
|
4655
|
+
elif ! cmp -s "$MIRROR_REPO_ROOT/$_mf" "$MIRROR_REPO_ROOT/$_mm"; then
|
|
4656
|
+
echo "❌ BLOCKED - Gate 12b: $_mm differs from canonical $_mf"
|
|
4657
|
+
echo " Run: node src/npm-package/scripts/sync-package-content.js --fix"
|
|
4658
|
+
MIRROR_FAIL=$((MIRROR_FAIL + 1))
|
|
4659
|
+
fi
|
|
4660
|
+
done <<< "$MIRROR_STAGED"
|
|
4661
|
+
if [ "$MIRROR_FAIL" -gt 0 ]; then
|
|
4662
|
+
echo ""
|
|
4663
|
+
echo "❌ BLOCKED - Gate 12b: $MIRROR_FAIL mirror parity violation(s)."
|
|
4664
|
+
echo " The mirror is what gets published; a mirror-only fix is silently"
|
|
4665
|
+
echo " reverted by the next prepack (v0.27.3 shipped that bug)."
|
|
4666
|
+
GATE_FAILED=1
|
|
4667
|
+
else
|
|
4668
|
+
echo "✅ PASSED - Gate 12b: mirror parity (touched files)"
|
|
4669
|
+
fi
|
|
4670
|
+
fi
|
|
4671
|
+
fi
|
|
4672
|
+
# ============================================================================
|
|
4673
|
+
|
|
4564
4674
|
# The compiler is the first and cheapest static analyser for a compiled language
|
|
4565
4675
|
# and XP-Gate never used it as one: cpp.sh only runs clang-tidy/cppcheck over the
|
|
4566
4676
|
# first 20 files, and Gate 9's build-integrity half is TypeScript-and-Maven only.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-668 `xp-gate audit assertions` 必须让存量断言债务可见
|
|
3
|
+
* @intent Gate 5d 只检查 **diff 范围内** 的文件。这是有意的——全仓扫描每次提交都跑
|
|
4
|
+
* 太慢——但它有个昂贵的副作用:存量债务会在 **无关改动** 时突然阻断,
|
|
5
|
+
* 制造错误的因果归因。原作者改了 `writeReport()` 的错误处理,却被
|
|
6
|
+
* `test-alignment.test.ts` 里 4 处既有的 `expect(true).toBe(true)` 拦下。
|
|
7
|
+
*
|
|
8
|
+
* 这个副作用最坏的后果不是那一次误阻断,而是它改变修复者的行为:归因噪音
|
|
9
|
+
* 让人以为"先绕过再说",于是倾向加 `@assertion-justified` 而不是真修掉。
|
|
10
|
+
* 一个让人更愿意豁免的门禁,比没有门禁更糟。
|
|
11
|
+
*
|
|
12
|
+
* 所以 `audit assertions` 的价值在于把"存量"变成可批处理的工作队列,
|
|
13
|
+
* 而不是让人在无关提交里逐个撞见。
|
|
14
|
+
* @covers AC-668-01, AC-668-02, AC-668-03
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { scanForAssertionDebt, summarizeDebt } = require('../assertion-debt');
|
|
19
|
+
|
|
20
|
+
describe('xp-gate audit assertions (#668)', () => {
|
|
21
|
+
it('AC-668-01: 列出全仓存量问题,按文件分组', () => {
|
|
22
|
+
const results = scanForAssertionDebt({ cwd: process.cwd() });
|
|
23
|
+
expect(Array.isArray(results)).toBe(true);
|
|
24
|
+
// Grouped by file so a batch fixer can work one file at a time.
|
|
25
|
+
for (const entry of results) {
|
|
26
|
+
expect(typeof entry.file).toBe('string');
|
|
27
|
+
expect(Array.isArray(entry.findings)).toBe(true);
|
|
28
|
+
for (const f of entry.findings) {
|
|
29
|
+
expect(typeof f.line).toBe('number');
|
|
30
|
+
expect(['tautological', 'swallowed-error']).toContain(f.kind);
|
|
31
|
+
expect(typeof f.text).toBe('string');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('AC-668-02: 识别两类确定性 vacuous 形态', () => {
|
|
37
|
+
// These are the two shapes Gate 5d blocks. Both are the ABSENCE of an assertion
|
|
38
|
+
// wearing assertion syntax, which is why they are the ones worth a debt list.
|
|
39
|
+
const fixture = [
|
|
40
|
+
'test("a", () => {',
|
|
41
|
+
' expect(true).toBe(true);',
|
|
42
|
+
' try { go(); } catch {}',
|
|
43
|
+
'});',
|
|
44
|
+
'test("b", () => {',
|
|
45
|
+
' expect(x).toBe(1);',
|
|
46
|
+
'});',
|
|
47
|
+
].join('\n');
|
|
48
|
+
const findings = scanForAssertionDebt({ source: fixture, file: 'fixture.test.ts' });
|
|
49
|
+
const flat = findings.flatMap((f) => f.findings);
|
|
50
|
+
expect(flat.some((f) => f.kind === 'tautological')).toBe(true);
|
|
51
|
+
expect(flat.some((f) => f.kind === 'swallowed-error')).toBe(true);
|
|
52
|
+
// A real assertion must not be reported -- a debt list that flags `expect(x).toBe(1)`
|
|
53
|
+
// is a list nobody finishes.
|
|
54
|
+
const realLine = flat.find((f) => f.text.includes('expect(x).toBe(1)'));
|
|
55
|
+
expect(realLine).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('AC-668-03: 摘要给出可批次清零的数量,而非只是行数', () => {
|
|
59
|
+
const fixture = ['expect(true).toBe(true);', 'expect(1).toBe(1);', 'try { x(); } catch {}'].join('\n');
|
|
60
|
+
const results = scanForAssertionDebt({ source: fixture, file: 'a.test.ts' });
|
|
61
|
+
const summary = summarizeDebt(results);
|
|
62
|
+
expect(summary.files).toBe(1);
|
|
63
|
+
expect(summary.total).toBe(3);
|
|
64
|
+
// Two tautologies and one empty catch: the split matters because they are fixed
|
|
65
|
+
// by different edits (rewrite the assertion vs. handle the error).
|
|
66
|
+
expect(summary.byKind.tautological).toBe(2);
|
|
67
|
+
expect(summary.byKind['swallowed-error']).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @test REQ-668 fixture 不能被当作存量债务
|
|
73
|
+
* @intent 这条测试防的是一个**修债务反而删掉测试覆盖**的失败模式。
|
|
74
|
+
*
|
|
75
|
+
* 测试 Gate 5d 分类器的那批 .bats / .test.ts 文件必然包含
|
|
76
|
+
* `'expect(true).toBe(true);'` —— 那是被校验的**输入**,不是断言。
|
|
77
|
+
* 若它们被列进"去修掉"的清单,执行清单的人会删掉钉住分类器行为的字符串,
|
|
78
|
+
* 于是债务清单本身成了覆盖率的破坏者。
|
|
79
|
+
*
|
|
80
|
+
* 反例同样重要:真正的裸断言 `expect(true).toBe(true);` 必须仍然被报出来。
|
|
81
|
+
* 只做"少报"很容易,难的是**同时**不误报也不漏报。
|
|
82
|
+
* @covers AC-668-04
|
|
83
|
+
*/
|
|
84
|
+
describe('fixture 与真实断言的区分 (#668)', () => {
|
|
85
|
+
it('AC-668-04: 带引号的 fixture 不报,裸断言照报', () => {
|
|
86
|
+
const { classifyLine } = require('../assertion-debt');
|
|
87
|
+
// Real code: must be reported.
|
|
88
|
+
expect(classifyLine('expect(true).toBe(true);')).toBe('tautological');
|
|
89
|
+
expect(classifyLine(' expect(1).toBe(1);')).toBe('tautological');
|
|
90
|
+
expect(classifyLine('try { x(); } catch {}')).toBe('swallowed-error');
|
|
91
|
+
// Fixture inputs: must not be reported.
|
|
92
|
+
expect(classifyLine("' expect(true).toBe(true);',")).toBeNull();
|
|
93
|
+
expect(classifyLine('const s = "expect(true).toBe(true)";')).toBeNull();
|
|
94
|
+
// Neither is debt.
|
|
95
|
+
expect(classifyLine('// expect(true).toBe(true);')).toBeNull();
|
|
96
|
+
expect(classifyLine('expect(x).toBe(1);')).toBeNull();
|
|
97
|
+
expect(classifyLine('expect(value).toBeTruthy();')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-652 BUILD 阶段的门禁清单必须从真相源生成,而非手抄
|
|
3
|
+
* @intent #652 的实证是:BUILD 阶段生成代码时,模型对 Boy Scout / CCN / jscpd /
|
|
4
|
+
* archlint 完全不可知——不是"知道却不遵守",是**根本不知道规则存在**。
|
|
5
|
+
* 后果是 27% 的门禁失败属于静态规则违规,即唯一 100% 可在生成前规避的类别。
|
|
6
|
+
*
|
|
7
|
+
* 补一页手写的阈值表能解决今天的问题,但会制造明天的问题:`.principlesrc`
|
|
8
|
+
* 里 `long-function` 是 50、`deep-nesting` 是 4,一旦有人调成 60/5,那一页
|
|
9
|
+
* 手抄的表就开始说谎。**一个说谎的自检清单比没有清单更糟**——它会让人按错的
|
|
10
|
+
* 规则写代码,然后在提交时被告知另一套。
|
|
11
|
+
*
|
|
12
|
+
* 所以这个清单必须从配置文件生成。真相源有几个就在几个里读,不复制。
|
|
13
|
+
* @covers AC-652-01, AC-652-02, AC-652-03
|
|
14
|
+
*/
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { collectGateRules } = require('../gate-rules');
|
|
20
|
+
|
|
21
|
+
const REPO = path.join(__dirname, '..', '..', '..', '..');
|
|
22
|
+
|
|
23
|
+
describe('BUILD 阶段门禁清单 (#652)', () => {
|
|
24
|
+
it('AC-652-01: 从 .principlesrc 读出真实阈值,不是写死的数字', () => {
|
|
25
|
+
const rules = collectGateRules({ cwd: REPO });
|
|
26
|
+
const longFn = rules.find((r) => r.id === 'clean-code.long-function');
|
|
27
|
+
const nesting = rules.find((r) => r.id === 'clean-code.deep-nesting');
|
|
28
|
+
expect(longFn).toBeDefined();
|
|
29
|
+
expect(nesting).toBeDefined();
|
|
30
|
+
// The value must come from the config file. Reading .principlesrc here keeps the
|
|
31
|
+
// test honest: if the config changes and the generator does not follow, this fails.
|
|
32
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(REPO, '.principlesrc'), 'utf8'));
|
|
33
|
+
expect(longFn.threshold).toBe(cfg.rules['clean-code']['long-function'].threshold);
|
|
34
|
+
expect(nesting.threshold).toBe(cfg.rules['clean-code']['deep-nesting'].threshold);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('AC-652-02: 覆盖会阻断提交的每一类规则,而不只是 principles', () => {
|
|
38
|
+
const rules = collectGateRules({ cwd: REPO });
|
|
39
|
+
const sources = new Set(rules.map((r) => r.source));
|
|
40
|
+
// #652 named exactly these four as absent from BUILD docs.
|
|
41
|
+
for (const required of ['principles', 'ccn', 'jscpd', 'boy-scout']) {
|
|
42
|
+
expect(sources).toContain(required);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('AC-652-03: 生成结果可渲染成一份自检清单,含阈值与出处', () => {
|
|
47
|
+
const rules = collectGateRules({ cwd: REPO });
|
|
48
|
+
for (const rule of rules) {
|
|
49
|
+
expect(typeof rule.id).toBe('string');
|
|
50
|
+
expect(typeof rule.source).toBe('string');
|
|
51
|
+
// Without the evidence pointer, "the threshold is 50" cannot be checked or
|
|
52
|
+
// argued with -- it is indistinguishable from a guess.
|
|
53
|
+
expect(typeof rule.evidence).toBe('string');
|
|
54
|
+
expect(rule.evidence).not.toBe('');
|
|
55
|
+
expect(['block', 'warn', 'info']).toContain(rule.severity);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-654 镜像清单必须自证完整:漏登记的规范源文件要报错,而不是静默不同步
|
|
3
|
+
* @intent 会话日志里 `mirror/sync` 类失败 **58 次**,git 历史反复出现
|
|
4
|
+
* `fix(mirror): ...` / `fix(package): 把 java.sh 改动落到真正的源文件`。
|
|
5
|
+
*
|
|
6
|
+
* 根因不是模型不知道规则——`AGENTS.md` 写得很清楚——而是这类约束是
|
|
7
|
+
* **结构性信息**:哪个文件是源、哪个是镜像、谁依赖谁。模型即使知道规则,
|
|
8
|
+
* 也要在多文件编辑中持续追踪映射关系,而这正是工具该做的事。
|
|
9
|
+
*
|
|
10
|
+
* 提示词救不了的部分,必须靠**漏登记即报错**来兜底。静默不同步是最坏的形态:
|
|
11
|
+
* 它让一次疏忽变成一次发布回滚(v0.27.3 正是只改镜像导致发布回滚)。
|
|
12
|
+
* @covers AC-654-01, AC-654-02, AC-654-03
|
|
13
|
+
*/
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const { findUnlistedSources, declareSources } = require('../mirror-manifest');
|
|
19
|
+
|
|
20
|
+
const REPO = path.join(__dirname, '..', '..', '..', '..');
|
|
21
|
+
|
|
22
|
+
describe('镜像清单完整性 (#654)', () => {
|
|
23
|
+
it('AC-654-01: 列出 canonical 目录下所有应被镜像的文件', () => {
|
|
24
|
+
const declared = declareSources({ cwd: REPO });
|
|
25
|
+
expect(Array.isArray(declared)).toBe(true);
|
|
26
|
+
// A manifest that silently returns nothing would pass every other assertion here,
|
|
27
|
+
// so pin the shape: real file lists are non-trivial.
|
|
28
|
+
expect(declared.length).toBeGreaterThan(20);
|
|
29
|
+
for (const entry of declared) {
|
|
30
|
+
expect(typeof entry.source).toBe('string');
|
|
31
|
+
expect(typeof entry.mirror).toBe('string');
|
|
32
|
+
// Both paths are repo-relative, forward-slashed, so drift messages are readable.
|
|
33
|
+
expect(entry.source.includes('\\')).toBe(false);
|
|
34
|
+
expect(entry.mirror.includes('\\')).toBe(false);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('AC-654-02: 实际存在的规范源文件若未登记,必须被报出来', () => {
|
|
39
|
+
const unlisted = findUnlistedSources({ cwd: REPO });
|
|
40
|
+
// The invariant the issue asks for: zero unlisted sources. This is the assertion
|
|
41
|
+
// that turns "someone forgot to register java.sh" from a publish-time rollback
|
|
42
|
+
// into a red test at commit time.
|
|
43
|
+
expect(unlisted).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('AC-654-03: 合成一个未登记文件时能被检出(防止检测器本身失效)', () => {
|
|
47
|
+
// A detector that never fires is indistinguishable from "no drift". Prove it fires
|
|
48
|
+
// on a synthetic tree rather than trusting the empty result above.
|
|
49
|
+
const fixture = {
|
|
50
|
+
'githooks/pre-commit': 'x',
|
|
51
|
+
'githooks/lib/known.sh': 'x',
|
|
52
|
+
'githooks/lib/orphan.sh': 'x',
|
|
53
|
+
};
|
|
54
|
+
const unlisted = findUnlistedSources({
|
|
55
|
+
cwd: REPO,
|
|
56
|
+
files: Object.keys(fixture),
|
|
57
|
+
declared: [
|
|
58
|
+
{ source: 'githooks/pre-commit', mirror: 'src/npm-package/hooks/pre-commit' },
|
|
59
|
+
{ source: 'githooks/lib/known.sh', mirror: 'src/npm-package/hooks/lib/known.sh' },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
expect(unlisted).toEqual(['githooks/lib/orphan.sh']);
|
|
63
|
+
});
|
|
64
|
+
});
|