@boyingliu01/xp-gate 0.33.1 → 0.34.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/gate-3.sh +48 -7
- package/gate-8.sh +32 -3
- package/gate-9.sh +14 -2
- package/gates/gate-7.ts +31 -10
- package/hooks/gate-3.sh +48 -7
- package/hooks/gate-8.sh +32 -3
- package/hooks/gate-9.sh +14 -2
- package/hooks/lib/assertion-quality.sh +232 -0
- package/hooks/lib/diff-coverage.sh +223 -0
- package/hooks/pre-commit +226 -2
- package/lib/mattpocock-license-verdict.json +1 -1
- 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/delphi-review/SKILL.md +30 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/claude-code/skills/test-driven-development/testing-anti-patterns.md +31 -0
- 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/delphi-review/SKILL.md +30 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/dsh/skills/test-driven-development/testing-anti-patterns.md +31 -0
- 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/delphi-review/SKILL.md +30 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/opencode/skills/test-driven-development/testing-anti-patterns.md +31 -0
- 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/delphi-review/SKILL.md +30 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-driven-development/SKILL.md +19 -0
- package/plugins/qoder/skills/test-driven-development/testing-anti-patterns.md +31 -0
- 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/delphi-review/SKILL.md +30 -0
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-driven-development/SKILL.md +19 -0
- package/skills/test-driven-development/testing-anti-patterns.md +31 -0
- package/skills/test-specification-alignment/AGENTS.md +2 -2
package/gate-3.sh
CHANGED
|
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
|
|
|
172
172
|
const entries = baseline.functions || baseline.entries || baseline;
|
|
173
173
|
if (Array.isArray(entries)) {
|
|
174
174
|
for (const e of entries) {
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// #632: accept the key the baseline WRITER actually emits.
|
|
176
|
+
// tools/Update-ComplexityBaseline.ps1 (the tool this gate
|
|
177
|
+
// points at in its own fix-it message) writes "function",
|
|
178
|
+
// while this reader only knew "name" -- so every entry was
|
|
179
|
+
// dropped, "known" stayed empty, and every function above
|
|
180
|
+
// the threshold became a brand-new function. The ratchet
|
|
181
|
+
// could never pass. Both spellings are read: this is a
|
|
182
|
+
// reader, and the writer vocabulary is not ours to
|
|
183
|
+
// redefine from here.
|
|
184
|
+
if (!e || !e.file) continue;
|
|
185
|
+
const fnName = e.name || e.function;
|
|
186
|
+
if (!fnName) continue;
|
|
187
|
+
known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
190
|
// The measurement emits ABSOLUTE paths (pwsh resolved them), while
|
|
@@ -529,13 +540,43 @@ else
|
|
|
529
540
|
# this, a crashing or mis-invoked lizard yields 0 warnings and the gate
|
|
530
541
|
# reports PASSED on zero analysed functions -- the same silent-success
|
|
531
542
|
# failure this issue is about.
|
|
543
|
+
#
|
|
544
|
+
# #666: "no rows" has TWO causes and they must not share a verdict. A
|
|
545
|
+
# module of pure constants legitimately has zero functions, and lizard
|
|
546
|
+
# emits no row for it at all (measured: `lizard pure_data.py --csv` prints
|
|
547
|
+
# nothing and exits 0). That was reported as `lizard_output_unparsable`
|
|
548
|
+
# /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
|
|
549
|
+
# data-only diff was indistinguishable from a tooling fault. The two are
|
|
550
|
+
# separated by a POSITIVE probe: ask lizard to describe a fixture known to
|
|
551
|
+
# contain a function. If it produces rows, lizard works and the input
|
|
552
|
+
# simply had no functions (SKIP, `no_functions_in_input`); if it does not,
|
|
553
|
+
# the tool really is broken (WARN, `lizard_output_unparsable`).
|
|
532
554
|
if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
|
|
533
555
|
END { exit(found ? 0 : 1) }'; then
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
556
|
+
_lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
|
|
557
|
+
_lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
|
|
558
|
+
printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
|
|
559
|
+
_lizard_probe_rows=0
|
|
560
|
+
if [ -f "$_lizard_probe_file" ]; then
|
|
561
|
+
_lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
|
|
562
|
+
| lizard_batches --csv 2>/dev/null \
|
|
563
|
+
| awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
|
|
564
|
+
fi
|
|
565
|
+
rm -rf "$_lizard_probe_dir" 2>/dev/null || true
|
|
566
|
+
if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
|
|
567
|
+
echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
|
|
568
|
+
echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
|
|
569
|
+
GATE_3_STATUS="SKIP"
|
|
570
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
571
|
+
# shellcheck disable=SC2034
|
|
572
|
+
GATE_3_REASON="no_functions_in_input"
|
|
573
|
+
else
|
|
574
|
+
echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
|
|
575
|
+
GATE_3_STATUS="WARN"
|
|
576
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
577
|
+
# shellcheck disable=SC2034
|
|
578
|
+
GATE_3_REASON="lizard_output_unparsable"
|
|
579
|
+
fi
|
|
539
580
|
elif [ "$CC_WARNINGS" -gt 0 ]; then
|
|
540
581
|
echo "$CC_OUTPUT"
|
|
541
582
|
echo ""
|
package/gate-8.sh
CHANGED
|
@@ -59,10 +59,24 @@ fi
|
|
|
59
59
|
# revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
|
|
60
60
|
# secret was never seen and Gate 8 always passed. `git --staged` scans
|
|
61
61
|
# `git diff --cached`, which is exactly the pre-commit payload (#449).
|
|
62
|
+
#
|
|
63
|
+
# #616: `--all` is a different question, and the index is the wrong place to ask
|
|
64
|
+
# it. Under `xp-gate check . --all` the user asked "is the whole tree clean", the
|
|
65
|
+
# index is usually empty, `git --staged` legitimately found nothing, and that
|
|
66
|
+
# nothing was counted as PASSED -- a secret sitting in a tracked file was
|
|
67
|
+
# invisible to the one command meant to find it. The runner already exports
|
|
68
|
+
# GATE_SCOPE (#589); this fragment just has to read it. `dir --no-git` scans the
|
|
69
|
+
# filesystem so tracked-but-unmodified files are covered.
|
|
62
70
|
GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
|
|
63
71
|
rm -f "$GITLEAKS_REPORT"
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
73
|
+
echo " Scope: --all (scanning the working tree, not the index)"
|
|
74
|
+
GITLEAKS_OUTPUT=$($GITLEAKS_CMD dir --no-git --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
|
|
75
|
+
GITLEAKS_EXIT=$?
|
|
76
|
+
else
|
|
77
|
+
GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
|
|
78
|
+
GITLEAKS_EXIT=$?
|
|
79
|
+
fi
|
|
66
80
|
|
|
67
81
|
if [ "$GITLEAKS_EXIT" -eq 0 ]; then
|
|
68
82
|
# Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
|
|
@@ -183,7 +197,22 @@ fi
|
|
|
183
197
|
# if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
|
|
184
198
|
if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
|
|
185
199
|
DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
|
|
186
|
-
|
|
200
|
+
# #616: same scope split as the gitleaks call above. Under `--all` the index is
|
|
201
|
+
# usually empty, so piping `git diff --cached -z` into xargs fed the hook zero
|
|
202
|
+
# files and it reported success without reading anything.
|
|
203
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
204
|
+
if git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
|
|
205
|
+
echo " ✅ detect-secrets PASSED - no new secrets beyond baseline (scope: --all)."
|
|
206
|
+
else
|
|
207
|
+
cat "$DS_TMP"
|
|
208
|
+
echo ""
|
|
209
|
+
echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
|
|
210
|
+
echo " If these are false positives, regenerate the baseline (cross-platform):"
|
|
211
|
+
echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
|
|
212
|
+
rm -f "$DS_TMP"
|
|
213
|
+
exit 1
|
|
214
|
+
fi
|
|
215
|
+
elif git diff --cached --name-only --diff-filter=ACM -z \
|
|
187
216
|
| xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
|
|
188
217
|
echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
|
|
189
218
|
else
|
package/gate-9.sh
CHANGED
|
@@ -32,8 +32,20 @@ if [ -z "$SEMGREP_CMD" ]; then
|
|
|
32
32
|
echo " Gate 9: SAST Security (WARN, semgrep not installed)"
|
|
33
33
|
GATE_9_STATUS="WARN"
|
|
34
34
|
else
|
|
35
|
-
# Get staged files filtered to Semgrep-supported languages
|
|
36
|
-
|
|
35
|
+
# Get staged files filtered to Semgrep-supported languages.
|
|
36
|
+
#
|
|
37
|
+
# #616: under `--all` the index is usually empty, so the staged diff yielded no
|
|
38
|
+
# files and the gate reported SKIPPED -- "no supported language files changed"
|
|
39
|
+
# -- for a run whose whole purpose was to scan the tree. Same fail-open shape
|
|
40
|
+
# #589 fixed in Gate 3, one layer down: the CLI says the tree was checked and
|
|
41
|
+
# the fragment checks a diff that does not exist. The runner exports GATE_SCOPE;
|
|
42
|
+
# read it and enumerate tracked files instead.
|
|
43
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
44
|
+
echo " Scope: --all (scanning tracked files, not the index)"
|
|
45
|
+
SEMGREP_FILES=$(git ls-files 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
|
|
46
|
+
else
|
|
47
|
+
SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
|
|
48
|
+
fi
|
|
37
49
|
|
|
38
50
|
if [ -z "$SEMGREP_FILES" ]; then
|
|
39
51
|
echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
|
package/gates/gate-7.ts
CHANGED
|
@@ -27,16 +27,13 @@ export function runGate7(input: GateInput): GateOutput {
|
|
|
27
27
|
return { status: 'PASS', messages, exitCode: 0 };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Run checkov if available (preferred tool)
|
|
30
|
+
// checkov is the PREFERRED engine (see the module header and the fallback
|
|
31
|
+
// message below), so it is tried FIRST. #650: the adapter lookup used to run
|
|
32
|
+
// before this and SKIP the gate whenever a consumer project had no vendored
|
|
33
|
+
// `githooks/adapters/iac.sh` -- even with checkov installed and on PATH. A
|
|
34
|
+
// security gate that skips because an optional helper script is absent, while
|
|
35
|
+
// the real scanner is available, reports "nothing to check" about code that was
|
|
36
|
+
// never checked. The bash adapter is the FALLBACK, so it is consulted after.
|
|
40
37
|
const checkov = isToolAvailable('checkov', cwd);
|
|
41
38
|
if (checkov.available) {
|
|
42
39
|
return runCheckov(checkov.path, checkov.via, iacFiles, cwd, startMs, messages);
|
|
@@ -46,6 +43,30 @@ export function runGate7(input: GateInput): GateOutput {
|
|
|
46
43
|
messages.push('⚠️ checkov not installed — falling back to bash IaC adapter');
|
|
47
44
|
messages.push(' Install: pip install checkov');
|
|
48
45
|
|
|
46
|
+
return runBashAdapter(iacFiles, cwd, startMs, messages);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fallback path: hand the IaC files to the project's bash adapter.
|
|
51
|
+
*
|
|
52
|
+
* Extracted from `runGate7` so the primary (checkov) path stays readable and the
|
|
53
|
+
* entry point stays inside the long-function limit. A missing adapter is a SKIP,
|
|
54
|
+
* never a PASS: no scan ran, so there is no evidence to report as clean.
|
|
55
|
+
*/
|
|
56
|
+
function runBashAdapter(
|
|
57
|
+
iacFiles: string[],
|
|
58
|
+
cwd: string,
|
|
59
|
+
startMs: number,
|
|
60
|
+
messages: string[]
|
|
61
|
+
): GateOutput {
|
|
62
|
+
const adapterPath = findIaCAdapter(cwd);
|
|
63
|
+
if (!adapterPath) {
|
|
64
|
+
messages.push('ℹ️ SKIP - IaC adapter not found');
|
|
65
|
+
messages.push('⏭️ SKIPPED - IaC security (no IaC adapter found)');
|
|
66
|
+
recordAudit('gate-7', 'iac-security', 'SKIP', 0, startMs);
|
|
67
|
+
return { status: 'SKIP', messages, exitCode: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
const result = runTool('bash', [adapterPath, ...iacFiles], { cwd });
|
|
50
71
|
const combinedOutput = result.stdout + result.stderr;
|
|
51
72
|
|
package/hooks/gate-3.sh
CHANGED
|
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
|
|
|
172
172
|
const entries = baseline.functions || baseline.entries || baseline;
|
|
173
173
|
if (Array.isArray(entries)) {
|
|
174
174
|
for (const e of entries) {
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// #632: accept the key the baseline WRITER actually emits.
|
|
176
|
+
// tools/Update-ComplexityBaseline.ps1 (the tool this gate
|
|
177
|
+
// points at in its own fix-it message) writes "function",
|
|
178
|
+
// while this reader only knew "name" -- so every entry was
|
|
179
|
+
// dropped, "known" stayed empty, and every function above
|
|
180
|
+
// the threshold became a brand-new function. The ratchet
|
|
181
|
+
// could never pass. Both spellings are read: this is a
|
|
182
|
+
// reader, and the writer vocabulary is not ours to
|
|
183
|
+
// redefine from here.
|
|
184
|
+
if (!e || !e.file) continue;
|
|
185
|
+
const fnName = e.name || e.function;
|
|
186
|
+
if (!fnName) continue;
|
|
187
|
+
known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
190
|
// The measurement emits ABSOLUTE paths (pwsh resolved them), while
|
|
@@ -529,13 +540,43 @@ else
|
|
|
529
540
|
# this, a crashing or mis-invoked lizard yields 0 warnings and the gate
|
|
530
541
|
# reports PASSED on zero analysed functions -- the same silent-success
|
|
531
542
|
# failure this issue is about.
|
|
543
|
+
#
|
|
544
|
+
# #666: "no rows" has TWO causes and they must not share a verdict. A
|
|
545
|
+
# module of pure constants legitimately has zero functions, and lizard
|
|
546
|
+
# emits no row for it at all (measured: `lizard pure_data.py --csv` prints
|
|
547
|
+
# nothing and exits 0). That was reported as `lizard_output_unparsable`
|
|
548
|
+
# /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
|
|
549
|
+
# data-only diff was indistinguishable from a tooling fault. The two are
|
|
550
|
+
# separated by a POSITIVE probe: ask lizard to describe a fixture known to
|
|
551
|
+
# contain a function. If it produces rows, lizard works and the input
|
|
552
|
+
# simply had no functions (SKIP, `no_functions_in_input`); if it does not,
|
|
553
|
+
# the tool really is broken (WARN, `lizard_output_unparsable`).
|
|
532
554
|
if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
|
|
533
555
|
END { exit(found ? 0 : 1) }'; then
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
556
|
+
_lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
|
|
557
|
+
_lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
|
|
558
|
+
printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
|
|
559
|
+
_lizard_probe_rows=0
|
|
560
|
+
if [ -f "$_lizard_probe_file" ]; then
|
|
561
|
+
_lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
|
|
562
|
+
| lizard_batches --csv 2>/dev/null \
|
|
563
|
+
| awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
|
|
564
|
+
fi
|
|
565
|
+
rm -rf "$_lizard_probe_dir" 2>/dev/null || true
|
|
566
|
+
if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
|
|
567
|
+
echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
|
|
568
|
+
echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
|
|
569
|
+
GATE_3_STATUS="SKIP"
|
|
570
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
571
|
+
# shellcheck disable=SC2034
|
|
572
|
+
GATE_3_REASON="no_functions_in_input"
|
|
573
|
+
else
|
|
574
|
+
echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
|
|
575
|
+
GATE_3_STATUS="WARN"
|
|
576
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
577
|
+
# shellcheck disable=SC2034
|
|
578
|
+
GATE_3_REASON="lizard_output_unparsable"
|
|
579
|
+
fi
|
|
539
580
|
elif [ "$CC_WARNINGS" -gt 0 ]; then
|
|
540
581
|
echo "$CC_OUTPUT"
|
|
541
582
|
echo ""
|
package/hooks/gate-8.sh
CHANGED
|
@@ -59,10 +59,24 @@ fi
|
|
|
59
59
|
# revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
|
|
60
60
|
# secret was never seen and Gate 8 always passed. `git --staged` scans
|
|
61
61
|
# `git diff --cached`, which is exactly the pre-commit payload (#449).
|
|
62
|
+
#
|
|
63
|
+
# #616: `--all` is a different question, and the index is the wrong place to ask
|
|
64
|
+
# it. Under `xp-gate check . --all` the user asked "is the whole tree clean", the
|
|
65
|
+
# index is usually empty, `git --staged` legitimately found nothing, and that
|
|
66
|
+
# nothing was counted as PASSED -- a secret sitting in a tracked file was
|
|
67
|
+
# invisible to the one command meant to find it. The runner already exports
|
|
68
|
+
# GATE_SCOPE (#589); this fragment just has to read it. `dir --no-git` scans the
|
|
69
|
+
# filesystem so tracked-but-unmodified files are covered.
|
|
62
70
|
GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
|
|
63
71
|
rm -f "$GITLEAKS_REPORT"
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
73
|
+
echo " Scope: --all (scanning the working tree, not the index)"
|
|
74
|
+
GITLEAKS_OUTPUT=$($GITLEAKS_CMD dir --no-git --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
|
|
75
|
+
GITLEAKS_EXIT=$?
|
|
76
|
+
else
|
|
77
|
+
GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
|
|
78
|
+
GITLEAKS_EXIT=$?
|
|
79
|
+
fi
|
|
66
80
|
|
|
67
81
|
if [ "$GITLEAKS_EXIT" -eq 0 ]; then
|
|
68
82
|
# Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
|
|
@@ -183,7 +197,22 @@ fi
|
|
|
183
197
|
# if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
|
|
184
198
|
if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
|
|
185
199
|
DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
|
|
186
|
-
|
|
200
|
+
# #616: same scope split as the gitleaks call above. Under `--all` the index is
|
|
201
|
+
# usually empty, so piping `git diff --cached -z` into xargs fed the hook zero
|
|
202
|
+
# files and it reported success without reading anything.
|
|
203
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
204
|
+
if git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
|
|
205
|
+
echo " ✅ detect-secrets PASSED - no new secrets beyond baseline (scope: --all)."
|
|
206
|
+
else
|
|
207
|
+
cat "$DS_TMP"
|
|
208
|
+
echo ""
|
|
209
|
+
echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
|
|
210
|
+
echo " If these are false positives, regenerate the baseline (cross-platform):"
|
|
211
|
+
echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
|
|
212
|
+
rm -f "$DS_TMP"
|
|
213
|
+
exit 1
|
|
214
|
+
fi
|
|
215
|
+
elif git diff --cached --name-only --diff-filter=ACM -z \
|
|
187
216
|
| xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
|
|
188
217
|
echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
|
|
189
218
|
else
|
package/hooks/gate-9.sh
CHANGED
|
@@ -32,8 +32,20 @@ if [ -z "$SEMGREP_CMD" ]; then
|
|
|
32
32
|
echo " Gate 9: SAST Security (WARN, semgrep not installed)"
|
|
33
33
|
GATE_9_STATUS="WARN"
|
|
34
34
|
else
|
|
35
|
-
# Get staged files filtered to Semgrep-supported languages
|
|
36
|
-
|
|
35
|
+
# Get staged files filtered to Semgrep-supported languages.
|
|
36
|
+
#
|
|
37
|
+
# #616: under `--all` the index is usually empty, so the staged diff yielded no
|
|
38
|
+
# files and the gate reported SKIPPED -- "no supported language files changed"
|
|
39
|
+
# -- for a run whose whole purpose was to scan the tree. Same fail-open shape
|
|
40
|
+
# #589 fixed in Gate 3, one layer down: the CLI says the tree was checked and
|
|
41
|
+
# the fragment checks a diff that does not exist. The runner exports GATE_SCOPE;
|
|
42
|
+
# read it and enumerate tracked files instead.
|
|
43
|
+
if [ "${GATE_SCOPE:-diff}" = "all" ]; then
|
|
44
|
+
echo " Scope: --all (scanning tracked files, not the index)"
|
|
45
|
+
SEMGREP_FILES=$(git ls-files 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
|
|
46
|
+
else
|
|
47
|
+
SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
|
|
48
|
+
fi
|
|
37
49
|
|
|
38
50
|
if [ -z "$SEMGREP_FILES" ]; then
|
|
39
51
|
echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Test-suite hygiene detectors for Gate 5, shared by every branch that needs them.
|
|
3
|
+
# Sourced by githooks/pre-commit and by the #602/#601 test batteries; kept in its
|
|
4
|
+
# own file so the contract is unit-testable without executing the hook.
|
|
5
|
+
#
|
|
6
|
+
# Why this exists at all: 指南 §9.5/§12 says a failing build must never be "fixed"
|
|
7
|
+
# by deleting assertions, widening mocks, or skipping tests. XP-Gate had no
|
|
8
|
+
# detector for the two quietest ways to do exactly that -- a committed `.only`
|
|
9
|
+
# (CI then runs one case and reports green) and a deleted test file with no
|
|
10
|
+
# replacement (the safety net vanishes with no signal). Gate 5a guarded the
|
|
11
|
+
# ADD side ("a new source file must have a test"); these functions guard the
|
|
12
|
+
# FOCUS/SKIP/DELETE side so the constraint is symmetric.
|
|
13
|
+
#
|
|
14
|
+
# The patterns live here, once, because Gate 5d (#601, assertion quality) needs
|
|
15
|
+
# the same vocabulary. Two copies of "what counts as a skip" drift apart, and the
|
|
16
|
+
# drift is invisible: each copy looks locally correct.
|
|
17
|
+
|
|
18
|
+
# JS/TS focus markers. In vitest and jest, the presence of `.only` in a file makes
|
|
19
|
+
# the whole file run ONLY that case; every other case is silently not executed and
|
|
20
|
+
# the run still reports success. `fit`/`fdescribe` are the jasmine aliases that
|
|
21
|
+
# vitest still accepts.
|
|
22
|
+
#
|
|
23
|
+
# Only the dotted/camel spellings are matched, not a bare `fit(`: `fit(` is also a
|
|
24
|
+
# common local helper name (curve fitting, layout fitting) and a false BLOCK on
|
|
25
|
+
# ordinary source would make the gate unusable. The anchored `\b(fit|fdescribe)\(`
|
|
26
|
+
# alternative below covers the alias form at a statement boundary only.
|
|
27
|
+
#
|
|
28
|
+
# Prints `line:text` for each hit. Empty output means "no focus markers".
|
|
29
|
+
classify_test_focus() {
|
|
30
|
+
grep -nE '(\.only[[:space:]]*\()|(\b(fit|fdescribe)[[:space:]]*\()' || true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Skip/todo markers across the five languages this repo gates. These are WARNINGS,
|
|
34
|
+
# not blocks: a skipped test is sometimes legitimate and always visible in the test
|
|
35
|
+
# runner, unlike `.only`. The warning is what makes the XP-Gate report say it
|
|
36
|
+
# counted fewer cases than the file contains.
|
|
37
|
+
#
|
|
38
|
+
# `@skip-justified: <reason >= 10 chars>` exempts one occurrence; see
|
|
39
|
+
# is_skip_justified() -- a bare annotation with no reason is not an exemption, so
|
|
40
|
+
# the escape hatch cannot become the default path.
|
|
41
|
+
classify_test_skip() {
|
|
42
|
+
grep -nE '(\.(skip|todo)[[:space:]]*\()|(\b(xit|xdescribe|xtest)[[:space:]]*\()|(@pytest\.mark\.skip)|(\bt\.Skip[f]?\()|(@Disabled\b)|(@Ignore\b)' || true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# A skip marker is exempt when the surrounding file carries
|
|
46
|
+
# `@skip-justified: <reason>` with a reason of at least 10 non-space characters.
|
|
47
|
+
# Splitting marker-detection from justification keeps each rule single-purpose and
|
|
48
|
+
# makes "the annotation exists but has no reason" a testable state rather than a
|
|
49
|
+
# silent pass.
|
|
50
|
+
#
|
|
51
|
+
# $1 = the source text to inspect (usually the staged file content).
|
|
52
|
+
is_skip_justified() {
|
|
53
|
+
printf '%s\n' "$1" \
|
|
54
|
+
| grep -oE '@skip-justified:[[:space:]]*[^[:space:]*].*' \
|
|
55
|
+
| sed 's/^@skip-justified:[[:space:]]*//' \
|
|
56
|
+
| tr -d '\r' \
|
|
57
|
+
| awk 'length($0) >= 10 { found = 1; exit } END { exit(found ? 0 : 1) }'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Files whose basename marks them as a test. Shared with classify_deleted_tests so
|
|
61
|
+
# "is this a test file" has one definition. Covers the naming conventions of the
|
|
62
|
+
# gated languages: *.test.ts / *.spec.js / *_test.py / test_*.py / *_test.go /
|
|
63
|
+
# *Test.java / *Tests.kt / *.Tests.ps1.
|
|
64
|
+
is_test_file_path() {
|
|
65
|
+
printf '%s' "$1" \
|
|
66
|
+
| grep -qE '(^|/)(__tests__/)?(test_[^/]+\.py|[^/]+_test\.(py|go)|[^/]+\.(test|spec)\.[a-z]+|[^/]*(Test|Tests|TestSuite)\.(java|kt|cs|scala)|[^/]+\.Tests\.ps1)$'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Deleted test files that have NO same-named replacement.
|
|
70
|
+
#
|
|
71
|
+
# A pure deletion and a rename look identical in `--name-only --diff-filter=D`;
|
|
72
|
+
# git reports the old path as deleted either way. Comparing against the added list
|
|
73
|
+
# is what separates "the safety net was removed" from "the file moved", and
|
|
74
|
+
# reporting a rename would train developers to ignore the warning.
|
|
75
|
+
#
|
|
76
|
+
# $1 = file with deleted paths (one per line), $2 = file with added paths.
|
|
77
|
+
# Prints the offending paths; empty output means every deletion was a rename.
|
|
78
|
+
classify_deleted_tests() {
|
|
79
|
+
local deleted_list added_list
|
|
80
|
+
deleted_list="$1"
|
|
81
|
+
added_list="$2"
|
|
82
|
+
[ -f "$deleted_list" ] || return 0
|
|
83
|
+
local path
|
|
84
|
+
while IFS= read -r path; do
|
|
85
|
+
[ -n "$path" ] || continue
|
|
86
|
+
is_test_file_path "$path" || continue
|
|
87
|
+
# The same basename anywhere in the added list counts as a replacement: a
|
|
88
|
+
# rename is usually same-directory, but moving a test module into
|
|
89
|
+
# `__tests__/` is the common case here and must not be flagged.
|
|
90
|
+
local base
|
|
91
|
+
base=$(printf '%s' "$path" | sed 's#.*/##')
|
|
92
|
+
if [ -f "$added_list" ] && grep -qF "$base" "$added_list" 2>/dev/null; then
|
|
93
|
+
continue
|
|
94
|
+
fi
|
|
95
|
+
printf '%s\n' "$path"
|
|
96
|
+
done < "$deleted_list"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Gate 5d (#601): assertion quality. 指南 §9.2 forbids six assertion
|
|
101
|
+
# anti-patterns; before this, nothing in XP-Gate looked at assertions at all --
|
|
102
|
+
# Gate M2/M3 examine how test doubles are built, never whether the test actually
|
|
103
|
+
# asserts anything. `expect(true).toBe(true)` and an empty `catch {}` pass every
|
|
104
|
+
# other gate while verifying nothing.
|
|
105
|
+
#
|
|
106
|
+
# Every function below maps to one anti-pattern in
|
|
107
|
+
# skills/test-driven-development/testing-anti-patterns.md, and says so, so a
|
|
108
|
+
# reviewer reading a block message knows which section to consult.
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# Anti-pattern 1: tautological assertion -- the assertion cannot fail. Two shapes,
|
|
112
|
+
# both BLOCK, because neither is a weak test: each is the absence of one wearing an
|
|
113
|
+
# assertion's syntax.
|
|
114
|
+
#
|
|
115
|
+
# (a) literal vs identical literal: `expect(true).toBe(true)`, `expect(1).toBe(1)`
|
|
116
|
+
# (b) a literal tested for the truthiness it already has:
|
|
117
|
+
# `expect(true).toBeTruthy()`, `expect(false).toBeFalsy()`,
|
|
118
|
+
# `expect("x").toBeTruthy()`, `expect(0).toBeFalsy()`, `expect(null).toBeFalsy()`
|
|
119
|
+
#
|
|
120
|
+
# (b) cannot be caught by (a)'s backreference, and the pairs are not equivalents to
|
|
121
|
+
# compare: `toBeTruthy`/`toBeFalsy` ask a different question than `toBe`. Matching
|
|
122
|
+
# the literal's CLASS against the matcher is what distinguishes them, so the two
|
|
123
|
+
# are separate alternatives rather than one clever expression.
|
|
124
|
+
#
|
|
125
|
+
# Deliberately NOT matched: `expect(x).toBe(x)` (same variable both sides). It is
|
|
126
|
+
# common in referential-identity checks (`expect(await load(a)).toBe(await load(a))`
|
|
127
|
+
# for memoisation) and a false block there is worse than the miss. Likewise
|
|
128
|
+
# `expect(someVar).toBeTruthy()` -- a variable's truthiness is a real question.
|
|
129
|
+
classify_tautological_assertions() {
|
|
130
|
+
# Implemented in awk rather than grep because POSIX grep -E has no portable
|
|
131
|
+
# backreference support: the Windows Git Bash grep used here matched nothing with
|
|
132
|
+
# `\1`, so a literal-vs-identical-literal check cannot be expressed there.
|
|
133
|
+
#
|
|
134
|
+
# The literal is extracted with a plain ERE-substitution loop instead of gawk's
|
|
135
|
+
# 3-argument match(), which mawk/BSD awk do not provide.
|
|
136
|
+
#
|
|
137
|
+
# Accepts stdin OR $1: the hook pipes the staged content in, while the test
|
|
138
|
+
# battery passes a string. Supporting only one of the two silently makes the
|
|
139
|
+
# other read nothing and report "no violations" -- the fail-open shape this
|
|
140
|
+
# whole release is about.
|
|
141
|
+
local _aq_src
|
|
142
|
+
if [ -n "${1:-}" ]; then _aq_src="$1"; else _aq_src=$(cat); fi
|
|
143
|
+
printf '%s\n' "$_aq_src" | awk '
|
|
144
|
+
BEGIN { TOL = "[[:space:]]*" }
|
|
145
|
+
function trim(s) { gsub(/^[[:space:]]+/, "", s); gsub(/[[:space:]]+$/, "", s); return s }
|
|
146
|
+
# (a) identical literal on both sides of toBe/toEqual/toStrictEqual.
|
|
147
|
+
/expect[^;]*\.to(Be|Equal|StrictEqual)[[:space:]]*\(/ {
|
|
148
|
+
line = $0
|
|
149
|
+
# Peel "expect(" ... ")" first, then the matcher argument.
|
|
150
|
+
if (match(line, /expect[[:space:]]*\([^()]*\)/)) {
|
|
151
|
+
left = substr(line, RSTART, RLENGTH)
|
|
152
|
+
sub(/^expect[[:space:]]*\(/, "", left); sub(/\)$/, "", left)
|
|
153
|
+
left = trim(left)
|
|
154
|
+
rest = substr(line, RSTART + RLENGTH)
|
|
155
|
+
if (match(rest, /\.to(Be|Equal|StrictEqual)[[:space:]]*\([^()]*\)/)) {
|
|
156
|
+
right = substr(rest, RSTART, RLENGTH)
|
|
157
|
+
sub(/^\.to(Be|Equal|StrictEqual)[[:space:]]*\(/, "", right); sub(/\)$/, "", right)
|
|
158
|
+
right = trim(right)
|
|
159
|
+
if (left != "" && left == right && left ~ /^(true|false|null|undefined|-?[0-9]+|".*"|\047.*\047)$/) {
|
|
160
|
+
print NR ":" line; next
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
# (b) a literal tested against the truthiness it already has.
|
|
166
|
+
/expect[[:space:]]*\([[:space:]]*(true|1|-1|"[^"]+"|\047[^\047]+\047)[[:space:]]*\)[[:space:]]*\.[[:space:]]*toBeTruthy[[:space:]]*\(/ { print NR ":" $0; next }
|
|
167
|
+
/expect[[:space:]]*\([[:space:]]*(false|0|null|undefined|""|\047\047)[[:space:]]*\)[[:space:]]*\.[[:space:]]*toBeFalsy[[:space:]]*\(/ { print NR ":" $0; next }
|
|
168
|
+
'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Anti-pattern 2: swallowed error -- a `catch` that neither asserts nor rethrows.
|
|
172
|
+
# The test cannot fail on the very path it was written to guard. BLOCK.
|
|
173
|
+
#
|
|
174
|
+
# Three spellings: `catch {}`, `catch (e) {}` (empty body), and `catch (e) { }`
|
|
175
|
+
# with only whitespace. A catch containing `expect`/`assert`/`throw`/`fail` is
|
|
176
|
+
# doing its job and is not matched.
|
|
177
|
+
classify_swallowed_errors() {
|
|
178
|
+
grep -nE 'catch([[:space:]]*\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' || true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Anti-patterns 3-5 (WARNING tier): weak-only assertions, mock-echo assertions, and
|
|
182
|
+
# call-order over-verification. These can be legitimate, so each is reported but
|
|
183
|
+
# never blocks.
|
|
184
|
+
#
|
|
185
|
+
# Implemented as a per-`it`/`test` block judgement rather than a per-line grep:
|
|
186
|
+
# "only weak assertions" is a property of the whole test body, and a line-wise
|
|
187
|
+
# check cannot tell `expect(x).toBeDefined()` alone from the same line followed by
|
|
188
|
+
# `expect(x).toBe(42)`.
|
|
189
|
+
#
|
|
190
|
+
# $1 = source text. Prints `line:reason` for each offending test block.
|
|
191
|
+
classify_weak_assertions() {
|
|
192
|
+
# Stdin or $1, for the same reason classify_tautological_assertions accepts both.
|
|
193
|
+
local _aq_src
|
|
194
|
+
if [ -n "${1:-}" ]; then _aq_src="$1"; else _aq_src=$(cat); fi
|
|
195
|
+
printf '%s\n' "$_aq_src" | awk '
|
|
196
|
+
# A block opens on it( / test( and closes on the matching "});" at column 0-2.
|
|
197
|
+
function flush() {
|
|
198
|
+
if (open && nassert > 0 && nstrong == 0) {
|
|
199
|
+
printf "%d:weak_assertion_only\n", startline
|
|
200
|
+
}
|
|
201
|
+
open = 0; nassert = 0; nstrong = 0
|
|
202
|
+
}
|
|
203
|
+
/(^|[^.[:alnum:]_])(it|test)(\.(only|skip|todo))?[[:space:]]*\(/ {
|
|
204
|
+
flush(); open = 1; startline = NR; next
|
|
205
|
+
}
|
|
206
|
+
open && /expect[[:space:]]*\(/ {
|
|
207
|
+
nassert++
|
|
208
|
+
# A "strong" assertion inspects a concrete value: not toBeDefined/toBeTruthy/
|
|
209
|
+
# toBeFalsy/toBeNull/toBeUndefined, and not a bare toHaveBeenCalled().
|
|
210
|
+
if ($0 !~ /\.(toBeDefined|toBeTruthy|toBeFalsy|toBeNull|toBeUndefined|toBeInstanceOf)[[:space:]]*\(/ \
|
|
211
|
+
&& $0 !~ /\.toHaveBeenCalled[[:space:]]*\([[:space:]]*\)/) {
|
|
212
|
+
nstrong++
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
open && /^\s*\}\);?\s*$/ { flush() }
|
|
216
|
+
END { flush() }
|
|
217
|
+
'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Anti-pattern 6 exemption: `@assertion-justified: <reason>`, mirroring
|
|
221
|
+
# `@mock-justified`. The reason must be at least 10 non-space characters -- an
|
|
222
|
+
# annotation without a reason is not an exemption, which is what stops the escape
|
|
223
|
+
# hatch from becoming the default.
|
|
224
|
+
#
|
|
225
|
+
# $1 = the source text to inspect.
|
|
226
|
+
is_assertion_justified() {
|
|
227
|
+
printf '%s\n' "$1" \
|
|
228
|
+
| grep -oE '@assertion-justified:[[:space:]]*[^[:space:]*].*' \
|
|
229
|
+
| sed 's/^@assertion-justified:[[:space:]]*//' \
|
|
230
|
+
| tr -d '\r' \
|
|
231
|
+
| awk 'length($0) >= 10 { found = 1; exit } END { exit(found ? 0 : 1) }'
|
|
232
|
+
}
|