| 1 |
#!/usr/bin/env bash |
| 2 |
# Pre-push gate — run the cheap checks CI will otherwise catch later. |
| 3 |
# |
| 4 |
# Mirrors the three green-required jobs of the `CI` workflow: |
| 5 |
# 1. ruff check |
| 6 |
# 2. mypy --strict |
| 7 |
# 3. pytest on the unit suite (no slow marker) |
| 8 |
# |
| 9 |
# Plus a best-effort grep for two patterns that CI has historically |
| 10 |
# failed on but unit tests don't catch: |
| 11 |
# - slow tests asserting `plan is not None` (CPU-only CI has no plan) |
| 12 |
# - tests pinning `dlm_version == N` for a stale N (schema bumps break these) |
| 13 |
# |
| 14 |
# Usage: |
| 15 |
# ./scripts/pregate.sh # runs the full gate |
| 16 |
# ./scripts/pregate.sh --fast # skip mypy + pytest, only ruff + pattern checks |
| 17 |
# ./scripts/pregate.sh --coverage # also mirror the Ubuntu package coverage gates |
| 18 |
# |
| 19 |
# Wire as a git hook with: |
| 20 |
# ln -s ../../scripts/pregate.sh .git/hooks/pre-push |
| 21 |
|
| 22 |
set -euo pipefail |
| 23 |
|
| 24 |
cd "$(git rev-parse --show-toplevel)" |
| 25 |
export UV_CACHE_DIR="${UV_CACHE_DIR:-.uv-cache}" |
| 26 |
|
| 27 |
fast=0 |
| 28 |
coverage=0 |
| 29 |
while [[ $# -gt 0 ]]; do |
| 30 |
case "$1" in |
| 31 |
--fast) |
| 32 |
fast=1 |
| 33 |
;; |
| 34 |
--coverage) |
| 35 |
coverage=1 |
| 36 |
;; |
| 37 |
*) |
| 38 |
echo "usage: ./scripts/pregate.sh [--fast] [--coverage]" >&2 |
| 39 |
exit 2 |
| 40 |
;; |
| 41 |
esac |
| 42 |
shift |
| 43 |
done |
| 44 |
|
| 45 |
echo "==> ruff check" |
| 46 |
uv run ruff check . |
| 47 |
|
| 48 |
echo "==> ruff format (check)" |
| 49 |
uv run ruff format --check . || { |
| 50 |
echo " hint: run \`uv run ruff format .\` to fix" |
| 51 |
exit 1 |
| 52 |
} |
| 53 |
|
| 54 |
if [[ $fast -eq 0 ]]; then |
| 55 |
echo "==> mypy --strict" |
| 56 |
uv run mypy src |
| 57 |
|
| 58 |
echo "==> pytest (unit; no slow marker)" |
| 59 |
uv run pytest tests/unit -q --no-header |
| 60 |
fi |
| 61 |
|
| 62 |
if [[ $coverage -eq 1 ]]; then |
| 63 |
echo "==> coverage gates (Ubuntu mirror)" |
| 64 |
./scripts/coverage-gates.sh |
| 65 |
fi |
| 66 |
|
| 67 |
# --- Pattern checks that mirror known CI foot-guns -------------------- |
| 68 |
|
| 69 |
echo "==> advisory: assert plan is not None in slow tests" |
| 70 |
# Slow-marked tests that hard-assert the plan can fail on CPU-only CI |
| 71 |
# unless they're upstream-guarded (e.g., `trained_store` fixture skips |
| 72 |
# first). This is advisory: a match is worth a look, not necessarily |
| 73 |
# a bug. Prefer `pytest.skip(...)` when the test body itself owns the |
| 74 |
# doctor() call. |
| 75 |
advisory_hits=$(git grep -n "assert plan is not None" -- 'tests/integration/**' 2>/dev/null || true) |
| 76 |
if [[ -n "$advisory_hits" ]]; then |
| 77 |
echo "$advisory_hits" | sed 's/^/ advisory: /' |
| 78 |
echo " (advisory only — confirm each has an upstream plan guard)" |
| 79 |
fi |
| 80 |
|
| 81 |
echo "==> modality scatter outside dlm.modality" |
| 82 |
# Sprint 38 B8.6: every `spec.modality == "..."` comparison lives in |
| 83 |
# src/dlm/modality/. Callers elsewhere go through predicate flags |
| 84 |
# (accepts_images, accepts_audio, requires_processor) or |
| 85 |
# modality_for(spec).dispatch_export() / .load_processor(). New |
| 86 |
# scatter means the modality abstraction is leaking again — refuse |
| 87 |
# the push and route the new code into the dispatch package instead. |
| 88 |
scatter=$(git grep -nE 'spec\.modality ==' -- 'src/dlm/**' 2>/dev/null | grep -v "src/dlm/modality/" || true) |
| 89 |
if [[ -n "$scatter" ]]; then |
| 90 |
echo "$scatter" |
| 91 |
echo " modality scatter outside src/dlm/modality/ — route through modality_for(spec)." |
| 92 |
exit 1 |
| 93 |
fi |
| 94 |
|
| 95 |
echo "==> new sprint jargon in src/dlm" |
| 96 |
# Sprint 39 M4: planning terms like `Sprint 23` or `audit-08` should |
| 97 |
# not leak into newly added product/runtime strings under src/dlm. |
| 98 |
# Compare the current tree against the upstream merge-base when one |
| 99 |
# exists, so committed fixes in the working tree override older |
| 100 |
# branch-local additions that have not been pushed yet. |
| 101 |
collect_src_dlm_diff() { |
| 102 |
local upstream |
| 103 |
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true) |
| 104 |
if [[ -n "$upstream" ]]; then |
| 105 |
local merge_base |
| 106 |
merge_base=$(git merge-base "$upstream" HEAD 2>/dev/null || true) |
| 107 |
if [[ -n "$merge_base" ]]; then |
| 108 |
git diff --unified=0 --no-color "$merge_base" -- 'src/dlm/**' 2>/dev/null || true |
| 109 |
return |
| 110 |
fi |
| 111 |
fi |
| 112 |
|
| 113 |
git diff --unified=0 --no-color HEAD -- 'src/dlm/**' 2>/dev/null || true |
| 114 |
} |
| 115 |
|
| 116 |
jargon_hits=$( |
| 117 |
collect_src_dlm_diff | awk ' |
| 118 |
/^diff --git / { |
| 119 |
file = $4 |
| 120 |
sub("^b/", "", file) |
| 121 |
next |
| 122 |
} |
| 123 |
/^\+\+\+ b\// { |
| 124 |
file = substr($0, 7) |
| 125 |
next |
| 126 |
} |
| 127 |
/^\+[^+]/ && ($0 ~ /Sprint [0-9]+/ || $0 ~ /audit-[0-9]+/) { |
| 128 |
print file ":" substr($0, 2) |
| 129 |
} |
| 130 |
' | sort -u |
| 131 |
) |
| 132 |
if [[ -n "$jargon_hits" ]]; then |
| 133 |
echo "$jargon_hits" |
| 134 |
echo " new Sprint/audit jargon leaked into src/dlm/ — translate it into product or operator language." |
| 135 |
exit 1 |
| 136 |
fi |
| 137 |
|
| 138 |
echo "==> stale dlm_version pin" |
| 139 |
# Any test that hard-pins a frontmatter version exact-match should use |
| 140 |
# >= so schema bumps don't retroactively break the test. Exact pins are |
| 141 |
# fine in unit-test-of-migrator paths (compares against a literal). |
| 142 |
stale=$(git grep -nE 'fm\.dlm_version ==|frontmatter\.dlm_version ==' -- 'tests/integration/**' 2>/dev/null || true) |
| 143 |
if [[ -n "$stale" ]]; then |
| 144 |
echo "$stale" |
| 145 |
echo " integration tests pinning dlm_version exact — prefer >= so schema bumps don't break." |
| 146 |
exit 1 |
| 147 |
fi |
| 148 |
|
| 149 |
echo "==> gate clean" |