SHELL := /bin/bash

.PHONY: build test lint check-semantic-contracts full-feature-check png-artifact-smoke release-gate release-gate-markers run clean spec spec-static spec-pr spec-contracts verify release-live-smoke validate-skills test-contracts install sync-python-dev test-rust test-python stress
.PHONY: landing-gate main-gate repo-audit installed-wheel-smoke
.PHONY: feature-coverage output-footprint nightly-checks
.PHONY: prepare-test prepare-test-contracts prepare-routine-test-tmp test-contracts-prepared prepare-spec

SPEC_PROFILE ?= dev
FULL_FEATURE_PROFILE ?= dev
FULL_FEATURE_BIN = target/$(if $(filter dev,$(FULL_FEATURE_PROFILE)),debug,$(FULL_FEATURE_PROFILE))/biomcp
ROUTINE_CARGO_FEATURES ?= --no-default-features
export ROUTINE_CARGO_FEATURES
export UV_PROJECT_ENVIRONMENT := $(CURDIR)/.venv
PYTEST_WORKERS = $(TEST_LANE_WORKERS)
PYTEST_XDIST_ARGS = -n $(PYTEST_WORKERS) --dist loadfile
# Cargo maps the dev profile to target/debug; every other profile name is
# its own directory under target/.
SPEC_BIN ?= $(CURDIR)/target/$(if $(filter dev,$(SPEC_PROFILE)),debug,$(SPEC_PROFILE))/biomcp
SPEC_USE_PROVIDED_BIN = $(shell if [ -n "$(BIOMCP_BIN)" ] && [ -x "$(BIOMCP_BIN)" ]; then echo yes; fi)
SPEC_RUN_BIN = $(if $(SPEC_USE_PROVIDED_BIN),$(BIOMCP_BIN),$(SPEC_BIN))
CARGO_WITH_IDENTITY = tools/with-build-identity cargo
ROUTINE_TEST_ARCHIVE = $(CURDIR)/.cache/routine-tests.tar.zst
ROUTINE_TEST_TMPDIR = $(CURDIR)/.cache/routine-test-tmp
PYTEST_BASETEMP = $(ROUTINE_TEST_TMPDIR)/pytest
# Every goal that runs the Python contract suite joins the routine
# lane: the suite's temp-path contract holds only while TMPDIR and
# pytest's basetemp stay in the worktree, and the offline sandbox
# otherwise points TMPDIR at its own /tmp (2148).
ifneq (,$(filter test test-rust test-python test-contracts test-contracts-prepared,$(MAKECMDGOALS)))
export TMPDIR = $(ROUTINE_TEST_TMPDIR)
export BIOMCP_ROUTINE_TEST_LANE = 1
endif
SPEC_BUILD = $(if $(SPEC_USE_PROVIDED_BIN),,$(CARGO_WITH_IDENTITY) build --locked --profile $(SPEC_PROFILE) $(ROUTINE_CARGO_FEATURES) --bin biomcp --example rmcp_streamable_http_contract)

sync-python-dev:
	uv sync --extra dev --no-install-project

.PHONY: gate-preflight
gate-preflight:
	bash tools/gate-tools

build:
	$(CARGO_WITH_IDENTITY) build --release

prepare-routine-test-tmp:
	mkdir -p "$(ROUTINE_TEST_TMPDIR)"

# The nextest archive builds the bin, the MCP example, and every test unit
# in one dev-profile cargo invocation; the contract build after it is a
# freshness check that compiles nothing unless the archive did not run.
prepare-test: prepare-routine-test-tmp
	mkdir -p "$(CURDIR)/.cache"
	$(CARGO_WITH_IDENTITY) nextest archive --locked $(ROUTINE_CARGO_FEATURES) --archive-file "$(ROUTINE_TEST_ARCHIVE)" --zstd-level -7
	$(MAKE) prepare-test-contracts

# The measured routine booking (8 GiB disk / 7 GiB memory) covers two
# workers in each sequential phase. Change this allowance with the booking,
# never from the shared host's CPU count.
TEST_LANE_WORKERS ?= 2
export CARGO_BUILD_JOBS = $(TEST_LANE_WORKERS)
NEXTEST_JOBS = $(TEST_LANE_WORKERS)
NEXTEST_JOBS_ARG = -j $(NEXTEST_JOBS)

test:
	$(MAKE) prepare-test
	$(MAKE) test-rust
	$(MAKE) test-python

# Both lanes consume the artifacts prepared once by `make test`. Each can
# also run independently against an existing prepared build.
test-rust: prepare-routine-test-tmp
	tools/run-offline -- python3 tools/report-slow-tests.py cargo nextest run --archive-file "$(ROUTINE_TEST_ARCHIVE)" $(NEXTEST_JOBS_ARG) --config-file "$(CURDIR)/.config/nextest.toml"

test-python: prepare-routine-test-tmp sync-python-dev
	$(MAKE) test-contracts-prepared

prepare-test-contracts:
	$(SPEC_BUILD)
	$(MAKE) sync-python-dev

# The contract gate runs in the routine lane shape (2148): the tmp
# dir must exist before the lane's exported TMPDIR carries the build.
test-contracts: prepare-routine-test-tmp prepare-test-contracts
	$(MAKE) test-contracts-prepared

test-contracts-prepared:
	tools/run-offline -- env $(if $(BIOMCP_ROUTINE_TEST_LANE),TMPDIR="$(TMPDIR)") BIOMCP_BIN="$(SPEC_RUN_BIN)" uv run --no-sync pytest tests/ -v $(PYTEST_XDIST_ARGS) -m "not release_gate and not repo_audit and not long" $(if $(BIOMCP_ROUTINE_TEST_LANE),--basetemp "$(PYTEST_BASETEMP)")
	tools/run-offline -- env NO_MKDOCS_2_WARNING=1 $(if $(BIOMCP_ROUTINE_TEST_LANE),TMPDIR="$(TMPDIR)") BIOMCP_BIN="$(SPEC_RUN_BIN)" uv run --no-sync mkdocs build --strict

# Exhaustive cases opt in by Rust long_suite_ name or Python long marker.
.PHONY: test-long
test-long: prepare-test
	tools/run-offline -- python3 tools/report-slow-tests.py cargo nextest run --archive-file "$(ROUTINE_TEST_ARCHIVE)" $(NEXTEST_JOBS_ARG) --config-file "$(CURDIR)/.config/nextest.toml" --profile long --no-tests=pass
	@status=0; tools/run-offline -- env BIOMCP_BIN="$(SPEC_RUN_BIN)" uv run --no-sync pytest tests/ -v $(PYTEST_XDIST_ARGS) -m "long and not release_gate" || status=$$?; test "$$status" -eq 0 -o "$$status" -eq 5

lint: gate-preflight
	python3 tools/check-feature-coverage.py links
	$(MAKE) sync-python-dev
	@tool_dir="$$(tools/bootstrap-lint-tools)" && \
		PATH="$$tool_dir:$$PATH" BIOMCP_ADVISORY_SCOPE="$(BIOMCP_ADVISORY_SCOPE)" ROUTINE_CARGO_FEATURES="$(ROUTINE_CARGO_FEATURES)" ./bin/lint
	tools/check-semantic-contracts.sh

# Keep commands in recipes so even make -j preserves the landing order.
# The existing landing gate runs on the merge candidate; main adds the
# shipped-feature build and behavior checks before the next landing.
landing-gate:
	$(MAKE) lint BIOMCP_ADVISORY_SCOPE="$(if $(filter always,$(BIOMCP_ADVISORY_SCOPE)),always,landing)"
	uv run --no-project python tools/check-replay-live --disable-sources commercial-restricted,semantic-scholar,kegg
	$(MAKE) test
	$(MAKE) spec

main-gate:
	$(MAKE) landing-gate BIOMCP_ADVISORY_SCOPE=always
	$(MAKE) repo-audit
	$(MAKE) full-feature-check
	$(MAKE) installed-wheel-smoke

installed-wheel-smoke: sync-python-dev
	bash scripts/check-linux-wheel.sh

nightly-checks:
	bash scripts/run-nightly-checks.sh

repo-audit: sync-python-dev
	tools/run-offline -- uv run --no-sync pytest tests/ -v $(PYTEST_XDIST_ARGS) -m "repo_audit and not release_gate"

full-feature-check:
	$(CARGO_WITH_IDENTITY) clippy --locked --all-targets --all-features -- -D warnings
	$(CARGO_WITH_IDENTITY) test --locked --all-features --lib sources::alphagenome::tests
	$(CARGO_WITH_IDENTITY) build --locked --profile $(FULL_FEATURE_PROFILE) --all-features --bin biomcp
	tools/check-png-artifact $(FULL_FEATURE_BIN)

png-artifact-smoke: build
	tools/check-png-artifact target/release/biomcp

# Nightly and both release entry points share the Linux-only region floor check.
# Routine lint only validates links and never builds with instrumentation.
feature-coverage:
	@echo "Collect outside-in Rust region coverage on a Linux host."
	NEXTEST_TEST_THREADS="$(TEST_LANE_WORKERS)" bash scripts/collect-feature-coverage.sh

release-gate-markers:
	$(MAKE) repo-audit
	$(MAKE) sync-python-dev
	tools/run-offline -- uv run --no-sync pytest tests/ -m release_gate -v $(PYTEST_XDIST_ARGS)
	$(MAKE) feature-coverage

release-gate: lint
	$(MAKE) test
	$(MAKE) test-long
	$(MAKE) release-gate-markers BIOMCP_BIN="$(SPEC_RUN_BIN)"
	$(MAKE) full-feature-check FULL_FEATURE_PROFILE=release
	$(MAKE) spec SPEC_PROFILE=release SPEC_BIN="$(CURDIR)/target/release/biomcp"

# Release lint remains unconditional even when a caller carries landing scope.
release-gate: override BIOMCP_ADVISORY_SCOPE = always

check-semantic-contracts:
	@bash tools/check-semantic-contracts.sh

output-footprint:
	$(SPEC_BUILD)
	$(MAKE) sync-python-dev
	BIOMCP_BIN="$(SPEC_RUN_BIN)" uv run --no-sync python benchmarks/output-footprint/run.py

run:
	$(CARGO_WITH_IDENTITY) run --

clean:
	cargo clean

install:
	mkdir -p "$(HOME)/.local/bin"
	$(CARGO_WITH_IDENTITY) build --release --locked
	install -m 755 target/release/biomcp "$(HOME)/.local/bin/biomcp"

prepare-spec:
	SPEC_PROFILE="$(SPEC_PROFILE)" BIOMCP_FEATURE_ON_BIN="$(if $(filter release,$(SPEC_PROFILE)),$(SPEC_BIN),)" bash scripts/run-specs.sh prepare-spec

spec:
	$(MAKE) prepare-spec
	$(MAKE) sync-python-dev
	tools/run-offline -- env BIOMCP_SPEC_ARTIFACTS_PREPARED=1 SPEC_PROFILE="$(SPEC_PROFILE)" BIOMCP_FEATURE_ON_BIN="$(if $(filter release,$(SPEC_PROFILE)),$(SPEC_BIN),)" bash scripts/run-specs.sh spec
	tools/run-offline -- $(MAKE) spec-static

spec-static:
	bash scripts/run-specs.sh spec-static

spec-pr:
	$(MAKE) sync-python-dev
	SPEC_PROFILE="$(SPEC_PROFILE)" BIOMCP_FEATURE_ON_BIN="$(if $(filter release,$(SPEC_PROFILE)),$(SPEC_BIN),)" bash scripts/run-specs.sh spec-pr

spec-contracts:
	$(MAKE) sync-python-dev
	SPEC_PROFILE="$(SPEC_PROFILE)" BIOMCP_FEATURE_ON_BIN="$(if $(filter release,$(SPEC_PROFILE)),$(SPEC_BIN),)" bash scripts/run-specs.sh spec-contracts

verify:
	$(CARGO_WITH_IDENTITY) build --release --locked
	$(CARGO_WITH_IDENTITY) nextest run --release --test rmcp_client_contract --run-ignored only
	$(CARGO_WITH_IDENTITY) nextest run --release --lib rmcp_stdio_recovers_from_tool_panic
	PATH="$${PWD}/target/release:$$PATH" BIOMCP_BIN="$${PWD}/target/release/biomcp" tools/biomcp-ci discover ERBB1
	PATH="$${PWD}/target/release:$$PATH" BIOMCP_BIN="$${PWD}/target/release/biomcp" tools/biomcp-ci search disease melanoma --limit 3
	PATH="$${PWD}/target/release:$$PATH" BIOMCP_BIN="$${PWD}/target/release/biomcp" tools/biomcp-ci search article -g BRAF --limit 3
	PATH="$${PWD}/target/release:$$PATH" BIOMCP_BIN="$${PWD}/target/release/biomcp" tools/biomcp-ci variant normalize all 'NM_000248.3:c.135del'
	BIOMCP_BIN="$${PWD}/target/release/biomcp" BIOMCP_FEATURE_ON_BIN="$${PWD}/target/release/biomcp" bash scripts/run-specs.sh verify
	BIOMCP_BIN="$${PWD}/target/release/biomcp" BIOMCP_FEATURE_ON_BIN="$${PWD}/target/release/biomcp" tools/biomcp-verify-live nih-reporter -- bash scripts/run-specs.sh verify-nih-reporter

release-live-smoke:
	$(MAKE) verify

validate-skills:
	$(MAKE) sync-python-dev
	PATH="$(CURDIR)/target/release:$(PATH)" \
		uv run --no-sync sh -c 'PATH="$(CURDIR)/target/release:$$PATH" ./scripts/validate-skills.sh'

# Stress lane: run the known load-flaky tests pinned to a two-CPU set
# with forced worker parallelism, repeated BIOMCP_STRESS_REPEAT times
# (default 3). The lane runs the prepared dev build — the CI job
# downloads the build job's archive instead of rebuilding it, and a
# local caller runs make prepare-test first (ticket 2075). The test
# invocations alone are pinned, because the runners auto-serialize on
# the detected CPU count.
# One-CPU pinning is avoided deliberately: it deadlocks the pipe
# handshake child deterministically (issue
# sdlc/issues/2026-09-25-single-cpu-affinity-deadlocks-the-handshake-child.md).
# The fixture-server case is the replay server behind the golden suite:
# its readiness handshake blocks on the server's greeting line, so a
# loaded host slows the run without turning startup into a race
# (the disease-survival fixture lifecycle test it replaces is gone with
# its fixture).
stress: prepare-routine-test-tmp
	@test -s "$(ROUTINE_TEST_ARCHIVE)" && test -x "$(SPEC_RUN_BIN)" || { \
	  echo "stress needs the prepared dev build at $(ROUTINE_TEST_ARCHIVE) and $(SPEC_RUN_BIN); run: make prepare-test" >&2; \
	  exit 2; }
	$(MAKE) sync-python-dev
	@set -euo pipefail; \
	repeat="$${BIOMCP_STRESS_REPEAT:-3}"; \
	scale="$${BIOMCP_TEST_TIMEOUT_SCALE:-6}"; \
	cpu="$$(python3 -c 'import os; print(min(os.sched_getaffinity(0)))')"; \
	for round in $$(seq 1 "$$repeat"); do \
	  echo "=== stress round $$round/$$repeat (CPU $$cpu, 4 workers, timeout scale $$scale) ==="; \
	  BIOMCP_TEST_TIMEOUT_SCALE="$$scale" taskset -c "$$cpu" tools/run-offline -- cargo nextest run --archive-file "$(ROUTINE_TEST_ARCHIVE)" -j 4 \
	    -E 'test(subprocess_lease_defers_old_generation_cleanup_until_reader_exits) | test(subprocess_lease_child_exits_on_parent_end_of_input) | test(cancelling_stalled_headers_and_streamed_body_drops_request_and_store_work) | test(cancelled_publication_settles_and_the_previous_generation_survives)'; \
	  BIOMCP_TEST_TIMEOUT_SCALE="$$scale" taskset -c "$$cpu" tools/run-offline -- env TMPDIR="$(TMPDIR)" BIOMCP_BIN="$(SPEC_RUN_BIN)" uv run --no-sync pytest tests/golden -n 4 --dist loadfile --basetemp "$(PYTEST_BASETEMP)"; \
	done
