#!/usr/bin/env bash # The full test run. Installs nothing into your interpreter: the standard library only. # Three steps borrow a tool through `uv`, into uv's own cache, and say so when they # cannot: the oldest promised Python, the linter, and the count of type errors. # The tests work on a synthetic fixture (tests/fixtures/profile) — they neither # read nor change anyone's real profile. set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" cd "$ROOT" export PYTHONPATH="src:tests${PYTHONPATH:+:$PYTHONPATH}" export SCHOLION_PROFILE_DIR="$ROOT/tests/fixtures/profile" export SCHOLION_OFFLINE=1 # The output language is pinned rather than inherited: with SCHOLION_LANG=ru in # the shell a developer would otherwise get a different run from CI, and a test # about language would pass or fail by accident of the terminal. export SCHOLION_LANG=en # The genome is switched off explicitly: otherwise, on the owner's machine, the # run would reach for a real VCF of tens of gigabytes (in iCloud that also means # downloading the file), and the result would depend on whose genome happens to # lie next to it. The tests are obliged to check the BEHAVIOUR of "the database # is not connected", which is the same for everybody. export SCHOLION_GENOME_VCF="$ROOT/tests/fixtures/no-such-file.vcf.gz" export SCHOLION_GENOME_DIR="$ROOT/tests/fixtures/no-genome" export SCHOLION_GENE_GFF3="$ROOT/tests/fixtures/no-annotation.gff3" # The workstation is switched off the same way (task 192). On a clinician's # machine the real one names patients' containers, and a test that dropped the # profile pin would otherwise be reading whichever patient is active. export SCHOLION_WORKSTATION="$ROOT/tests/fixtures/no-workstation.json" # Guard against the usual "command + comment on one line" paste: in interactive # zsh a `#` does NOT start a comment, and the words after the hash arrive here as # arguments. unittest answers with its usage text, which does not look like "the # tests never ran at all" and is easily mistaken for a successful run. So the # refusal is spelled out in words. # Both entry points use the same selector validation in run_tests.py. # Cheap metadata failures precede a costly suite. The Python runner owns the # same preflight on platforms without bash. if [ "$#" -eq 0 ]; then python3 src/tools/run_tests.py --preflight-only < /dev/null fi REACH_RAN=0 if [ "$#" -eq 0 ] && [ -d share ] && [ -f src/tools/check_test_reach.py ] \ && [ "${SCHOLION_SKIP_REACH:-}" != "1" ] && [ "${SCHOLION_SKIP_REACH:-}" != "true" ] \ && [ "${SCHOLION_SKIP_REACH:-}" != "yes" ]; then echo "▶ full tests and statement reach in one run (synthetic profile, offline)" python3 src/tools/check_test_reach.py --strict < /dev/null REACH_RAN=1 else echo "▶ tests (SCHOLION_OFFLINE=1 — synthetic profile only)" python3 src/tools/run_tests.py --suite-only "$@" < /dev/null fi echo "▶ backward compatibility of the public contract" python3 src/tools/check_compat.py # These steps run only if the tool is present in this build: the anonymised # package does not carry every internal tool, and a run at the recipient's end # must not fail over the absence of something they were never given. if [ -f src/tools/sync_docs.py ]; then echo "▶ the documents inside the package match their sources" python3 src/tools/sync_docs.py || exit 1 fi if [ -f src/tools/sync_manifest.py ]; then echo "▶ the host manifest matches the build" python3 src/tools/sync_manifest.py || exit 1 fi # The plugin folder as Claude's own validator reads it — the check the catalogue # runs on submission, run here first. It needs the Claude command-line tool, # which this project neither installs nor requires; where it is absent the step # says so rather than passing in silence. if [ -d agent-plugin/.claude-plugin ]; then if [ "${SCHOLION_SKIP_PLUGIN_VALIDATE:-}" = "1" ] || [ "${SCHOLION_SKIP_PLUGIN_VALIDATE:-}" = "true" ]; then echo "▶ the Claude plugin validates — skipped by SCHOLION_SKIP_PLUGIN_VALIDATE" elif command -v claude > /dev/null 2>&1; then echo "▶ the Claude plugin validates (claude plugin validate --strict; SCHOLION_SKIP_PLUGIN_VALIDATE=1 skips)" claude plugin validate --strict agent-plugin || exit 1 else echo "▶ the Claude plugin validates — not checked: the claude command is not installed here" fi fi if [ -f src/tools/sync_rules.py ]; then echo "▶ the assistant rules are in sync with ASSISTANT-RULES.md" python3 src/tools/sync_rules.py fi # The suite on the OLDEST Python the package promises, run BEFORE a tag instead # of after one. # # `requires-python = ">=3.10"` is a promise to everyone who installs this, and # until now nothing on this machine checked it: the matrix that does lives in the # public repository and is reached only through publication. It answered twice in # two days, both times about a tag that was already out — once with a failed # release build, once with a red matrix on a published version. Both were the # same shape: verified on one interpreter, promised about four. # # The floor is read from pyproject.toml rather than written here. Three places # already name it — the promise, the matrix, and this — and a number typed into # each is three numbers. # # `uv` fetches the interpreter and caches it; a second run costs half a second # plus the suite. Where uv is absent the step says so and does not pretend: a # check that cannot run is not a check that passed. if [ "$#" -gt 0 ]; then : elif [ "${SCHOLION_SKIP_OLDEST:-}" = "1" ] || [ "${SCHOLION_SKIP_OLDEST:-}" = "true" ]; then echo "▶ the oldest supported Python — skipped by SCHOLION_SKIP_OLDEST" elif [ "${CI:-}" = "true" ]; then echo "▶ the oldest supported Python — not here: the matrix runs every version it promises" elif [ ! -f pyproject.toml ]; then echo "▶ the oldest supported Python — not asked: no pyproject.toml here to read the floor from" else FLOOR="$(sed -n 's/^requires-python[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' pyproject.toml | head -1)" HERE="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')" case "$FLOOR" in [0-9]*.[0-9]*) ;; *) FLOOR="" ;; esac if [ -z "$FLOOR" ]; then echo "▶ the oldest supported Python — not asked: pyproject.toml names no floor" elif [ "$FLOOR" = "$HERE" ]; then echo "▶ the oldest supported Python — this IS $FLOOR, already covered by the run above" elif ! command -v uv >/dev/null 2>&1; then echo "⚠ the oldest supported Python ($FLOOR) was NOT run: uv is not installed, and there is" echo " nothing else here to fetch that interpreter with. This machine runs $HERE, so" echo ' requires-python is unverified until the matrix answers — after a tag.' else echo "▶ the suite on Python $FLOOR, the oldest this package promises (uv fetches it; SCHOLION_SKIP_OLDEST=1 skips)" uv run --python "$FLOOR" --no-project -- python -m unittest discover -s tests -t . < /dev/null || exit 1 fi fi # The linter of the public matrix, run before a publication rather than after it. # # The same arrangement as the count of type errors below, for the same reason: # the job that runs `ruff` lives in the public repository and answers after a # tag, and 0.5.9 went out with two findings it would have named. Two tests hold # the classes that cost that release on the standard library alone; this step # holds the rest of the rule set, where `uv` can fetch the tool. # # Both the version and the command are read from the workflow, so that what is # checked here is what will be checked there. if [ -f .github/workflows/tests.yml ]; then if [ "$#" -gt 0 ]; then echo "▶ the linter — not run: this run was narrowed to $*" elif [ "${SCHOLION_SKIP_LINT:-}" = "1" ] || [ "${SCHOLION_SKIP_LINT:-}" = "true" ]; then echo "▶ the linter — skipped by SCHOLION_SKIP_LINT" elif [ "${CI:-}" = "true" ]; then echo "▶ the linter — not here: the matrix has a job of its own for it" else LINT_PIN="$(sed -n 's/.*"\(ruff==[0-9][0-9.]*\)".*/\1/p' .github/workflows/tests.yml | head -1)" LINT_ARGS="$(sed -n 's/^ *run: ruff \(check [A-Za-z0-9_ ./-]*\)$/\1/p' .github/workflows/tests.yml | head -1)" if [ -z "$LINT_PIN" ] || [ -z "$LINT_ARGS" ]; then echo "⚠ the linter was NOT run: the workflow here pins no ruff version, or names no command for it." elif ! command -v uv >/dev/null 2>&1; then echo "⚠ the linter was NOT run: uv is not installed, and there is nothing else here to fetch" echo " $LINT_PIN with. Its findings are unknown until the matrix answers — after a tag." elif ! uv run --no-project --with "$LINT_PIN" -- ruff --version >/dev/null 2>&1; then echo "⚠ the linter was NOT run: uv could not provide $LINT_PIN — no network, and nothing" echo " cached. Its findings are unknown until the matrix answers — after a tag." else echo "▶ the linter finds nothing ($LINT_PIN $LINT_ARGS, fetched by uv; SCHOLION_SKIP_LINT=1 skips)" # shellcheck disable=SC2086 # the arguments are words, read from the workflow uv run --no-project --with "$LINT_PIN" -- ruff $LINT_ARGS --quiet || exit 1 echo " ✓ zero findings" fi fi fi # Type errors per module, counted before publication rather than after it. # # The matrix has a job for this and it answers after a tag: it was red on two # published versions in a row, the second time for a count that had grown in # work already out. `mypy` is a third-party tool and the package depends on # nothing, so nothing is installed into anybody's interpreter: `uv` fetches the # tool into its own cache, the same way it fetches the oldest Python above. # # The version is read from the workflow that pins it. The count depends on the # version, and a number typed here as well would be two numbers. # # Where it cannot run it says so and the run goes on: a check that did not run # is not a check that passed, and it is not a failure of the tree either. if [ -f src/tools/check_types.py ]; then if [ "$#" -gt 0 ]; then echo "▶ type errors per module — not measured: this run was narrowed to $*" elif [ "${SCHOLION_SKIP_TYPES:-}" = "1" ] || [ "${SCHOLION_SKIP_TYPES:-}" = "true" ]; then echo "▶ type errors per module — skipped by SCHOLION_SKIP_TYPES" elif [ "${CI:-}" = "true" ]; then echo "▶ type errors per module — not here: the matrix has a job of its own for them" else TYPES_PIN="" if [ -f .github/workflows/tests.yml ]; then TYPES_PIN="$(sed -n 's/.*"\(mypy==[0-9][0-9.]*\)".*/\1/p' .github/workflows/tests.yml | head -1)" fi if [ -z "$TYPES_PIN" ]; then echo "⚠ type errors were NOT measured: no workflow here pins a mypy version to measure with." elif ! command -v uv >/dev/null 2>&1; then echo "⚠ type errors were NOT measured: uv is not installed, and there is nothing else here" echo " to fetch $TYPES_PIN with. The count is unverified until the matrix answers — after a tag." elif ! uv run --no-project --with "$TYPES_PIN" -- python -c "import mypy" >/dev/null 2>&1; then echo "⚠ type errors were NOT measured: uv could not provide $TYPES_PIN — no network, and" echo " nothing cached. The count is unverified until the matrix answers — after a tag." else echo "▶ no module gained a type error ($TYPES_PIN, fetched by uv; SCHOLION_SKIP_TYPES=1 skips)" uv run --no-project --with "$TYPES_PIN" -- python src/tools/check_types.py --strict > /dev/null 2>&1 || { uv run --no-project --with "$TYPES_PIN" -- python src/tools/check_types.py --strict | tail -4 exit 1 } echo " ✓ every module is at or below its recorded count" fi fi fi # The reach of the suite over the code, measured rather than assumed. The full # source suite above already records it. Never execute that same suite again # merely to obtain coverage. Narrowed runs cannot replace full-suite evidence. # # Why it is here at all and not in CI: the matrix in .github/workflows/tests.yml # runs in the PUBLIC repository, which this tree reaches only through # publish_share.sh. A gate there answers after publication. This is the only # place that answers before it. # # A narrowed run is skipped rather than measured: `./run_tests.sh tests.test_x` # executes a fraction of the suite, and comparing that fraction's reach against a # baseline taken from the whole would fail for the one reason that is not a # defect. if [ -f src/tools/check_test_reach.py ]; then if [ "$REACH_RAN" = "1" ]; then echo "▶ reach of the suite — passed with the primary full run" elif [ "$#" -gt 0 ]; then echo "▶ reach of the suite — not measured: this run was narrowed to $*" elif [ "${SCHOLION_SKIP_REACH:-}" = "1" ] || [ "${SCHOLION_SKIP_REACH:-}" = "true" ] || [ "${SCHOLION_SKIP_REACH:-}" = "yes" ]; then echo "▶ reach of the suite — skipped by SCHOLION_SKIP_REACH" else echo "▶ no module lost reach over the code (full suite; SCHOLION_SKIP_REACH=1 skips it)" python3 src/tools/check_test_reach.py --strict || exit 1 fi fi # A broad `except` that swallows an error must say why that is safe (task 209): # the class behind «a failed read of the genome answered reference». Instant — # an AST pass over src/ — so it runs on every invocation, narrowed or not. if [ -f src/tools/check_quiet_excepts.py ]; then echo "▶ every silent broad except says why its silence is safe" python3 src/tools/check_quiet_excepts.py --strict > /dev/null || { python3 src/tools/check_quiet_excepts.py --strict exit 1 } echo " ✓ none unexplained" fi if [ -f src/tools/check_language.py ]; then echo "▶ Russian has not been added to what ships" python3 src/tools/check_language.py --strict > /dev/null || { python3 src/tools/check_language.py --strict exit 1 } echo " ✓ the remainder did not grow" fi