From e9e9168921abd7c22a4d346e8f74c0dc73ac2e31 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 7 Jun 2026 19:49:17 +0200 Subject: [PATCH] fix: stabilize consistency make wrappers --- Makefile | 58 ++++++------ scripts/consistency_check.py | 23 +++-- tests/test_consistency_check.py | 88 +++++++++++++++++++ ...TE-WP-0060-fix-consistency-cross-flavor.md | 37 +++++++- 4 files changed, 169 insertions(+), 37 deletions(-) diff --git a/Makefile b/Makefile index 5075d05..6283b01 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env PYTHON ?= python3 +# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable. +UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi) start: @echo "# run in different terminals" @@ -12,7 +14,7 @@ start: @echo "make bridges # Set up ssh bridges for cross machines access" install: - uv sync + $(UV) sync dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json cd dashboard && npm ci @@ -39,17 +41,17 @@ db-tools: $(COMPOSE) --profile tools up -d migrate: - uv run alembic upgrade head + $(UV) run alembic upgrade head seed: - uv run python scripts/seed.py + $(UV) run python scripts/seed.py ## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code. ## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001). ## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}' mcp-http: @fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true - MCP_TRANSPORT=sse MCP_PORT=8001 uv run python mcp_server/server.py + MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py dashboard: @fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true @@ -63,7 +65,7 @@ test: test-python dashboard-check test-python: TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \ - uv run pytest -x -q + $(UV) run pytest -x -q ## ops-bridge managed tunnels ## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge @@ -100,7 +102,7 @@ api: db done $(MAKE) migrate @fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true - uv run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000 + $(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000 ## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance register-project: @@ -168,7 +170,7 @@ list-repos: ## Tip: run capture-tools first for repos with system-level tool dependencies. ingest-sbom: @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) - uv run python scripts/ingest_sbom.py --repo "$(REPO)" \ + $(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \ $(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ $(if $(DRY_RUN),--dry-run) @@ -179,12 +181,12 @@ ingest-sbom: ## Add DRY_RUN=1 to preview without writing. ingest-capabilities: @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) - uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \ + $(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ $(if $(DRY_RUN),--dry-run) ingest-capabilities-all: - uv run python scripts/ingest_capabilities.py --all \ + $(UV) run python scripts/ingest_capabilities.py --all \ $(if $(DRY_RUN),--dry-run) ## Check Repository Definition of Integrated (DoI) criteria for a repo. @@ -193,10 +195,10 @@ ingest-capabilities-all: ## Add JSON=1 for machine-readable output. check-doi: @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) - uv run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json) + $(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json) check-doi-all: - uv run python scripts/check_doi.py --all $(if $(JSON),--json) + $(UV) run python scripts/check_doi.py --all $(if $(JSON),--json) ## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog. ## Usage: make ingest-tpsc REPO=llm-connect @@ -204,11 +206,11 @@ check-doi-all: ## Add DRY_RUN=1 to preview without writing. ingest-tpsc: @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) - uv run python scripts/ingest_tpsc.py --repo "$(REPO)" \ + $(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \ $(if $(DRY_RUN),--dry-run) ingest-tpsc-all: - uv run python scripts/ingest_tpsc.py --all \ + $(UV) run python scripts/ingest_tpsc.py --all \ $(if $(DRY_RUN),--dry-run) ## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml. @@ -216,29 +218,33 @@ ingest-tpsc-all: ## Add DRY_RUN=1 to preview without writing. capture-tools: @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) - uv run python scripts/capture_sbom_tools.py --repo "$(REPO)" \ + $(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ $(if $(DRY_RUN),--dry-run) ## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian] validate-adr: @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO= [DOMAIN=]"; exit 1) - uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",) + $(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",) +## Consistency exit contract: +## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures. +## - Agent/operator Make wrappers below normalize warning-only 2 to shell success +## while preserving visible WARN output and keeping real failures non-zero. ## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override] -## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures check-consistency: @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO="; exit 1) - uv run python scripts/consistency_check.py --repo "$(REPO)" \ + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" \ $(if $(API_BASE),--api-base "$(API_BASE)",) \ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e ## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override] -## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures +## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures fix-consistency: @test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO="; exit 1) - uv run python scripts/consistency_check.py --repo "$(REPO)" --fix \ + $(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \ $(if $(API_BASE),--api-base "$(API_BASE)",) \ $(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \ e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e @@ -258,7 +264,7 @@ token-reconcile: ## make fix-consistency-remote — smart pull+fix all repos that need it ## make fix-consistency-remote REPO=slug — pull+fix one repo fix-consistency-remote: - uv run python scripts/consistency_check.py \ + $(UV) run python scripts/consistency_check.py \ $(if $(REPO),--repo "$(REPO)",--all) \ --remote \ $(if $(API_BASE),--api-base "$(API_BASE)",) \ @@ -268,14 +274,14 @@ fix-consistency-remote: ## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo] ## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag). check-consistency-here: - uv run python scripts/consistency_check.py \ + $(UV) run python scripts/consistency_check.py \ --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ $(if $(API_BASE),--api-base "$(API_BASE)",); \ e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e ## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo] fix-consistency-here: - uv run python scripts/consistency_check.py \ + $(UV) run python scripts/consistency_check.py \ --here $(if $(REPO_PATH),"$(REPO_PATH)",) \ --fix \ $(if $(API_BASE),--api-base "$(API_BASE)",); \ @@ -283,19 +289,19 @@ fix-consistency-here: ## Check all registered repos for ADR-001 consistency check-consistency-all: - uv run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \ + $(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \ e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e ## Check and auto-fix all registered repos fix-consistency-all: - uv run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \ + $(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \ e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e ## Cancel open tasks belonging to completed/archived workstreams. ## Safe to run at any time; also suitable for a daily cron job. ## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale cleanup-stale: - uv run python scripts/cleanup_stale_tasks.py + $(UV) run python scripts/cleanup_stale_tasks.py ## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx install-hooks: @@ -314,7 +320,7 @@ remove-hooks: ## Compare Gitea coulomb org repos against state-hub registered repos ## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN= gitea-inventory: - uv run python scripts/gitea_inventory.py $(if $(JSON),--json) + $(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json) clean: $(COMPOSE) down -v diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index d60f9c7..43a07b8 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -33,9 +33,13 @@ Usage: python scripts/consistency_check.py --here [PATH] [--fix] [--no-writeback] [--json] [--api-base URL] Exit codes: - 0 — ok (no FAILs; only WARNs/INFOs) + 0 — clean (no FAILs or WARNs; INFOs are allowed) 1 — one or more FAILs present - 2 — warn-only (no FAILs, but WARNs present) + 2 — warnings-only strict CLI result (no FAILs, but WARNs present) + +Agent/operator Make wrappers normalize exit code 2 to shell success while +preserving visible warning output. Use the direct script when a machine caller +needs to distinguish clean from warnings-only. """ from __future__ import annotations @@ -2161,6 +2165,15 @@ def report_to_dict(report: ConsistencyReport) -> dict: } +def consistency_exit_code(reports: list[ConsistencyReport], *, remote_all: bool = False) -> int: + """Return the strict CLI exit code for consistency reports.""" + any_fail = any(r.failures for r in reports) + any_warn = any(r.warnings for r in reports) + if remote_all and not any_fail: + return 0 + return 1 if any_fail else 2 if any_warn else 0 + + # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- @@ -2329,11 +2342,7 @@ def main() -> None: print(render_text(report)) print() - any_fail = any(r.failures for r in reports) - any_warn = any(r.warnings for r in reports) - if args.remote and args.all and not any_fail: - sys.exit(0) - sys.exit(1 if any_fail else 2 if any_warn else 0) + sys.exit(consistency_exit_code(reports, remote_all=args.remote and args.all)) if __name__ == "__main__": diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index c452698..07e7d8a 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -12,6 +12,9 @@ No network calls, no DB, no live API — these tests run fully offline. """ from __future__ import annotations +import os +import shutil +import subprocess import sys from pathlib import Path @@ -35,6 +38,7 @@ from consistency_check import ( archive_closed_workplans, canonical_workplan_filename, check_repo, + consistency_exit_code, fix_repo, get_tasks_from_workplan, iter_workplan_files, @@ -488,6 +492,90 @@ class TestReportToDict: assert d["repo_path"] == "/home/worsch/the-custodian" +# --------------------------------------------------------------------------- +# Consistency exit contract +# --------------------------------------------------------------------------- + +class TestConsistencyExitContract: + def _report(self, severity: str | None = None) -> ConsistencyReport: + r = ConsistencyReport(repo_slug="r", repo_path="/p") + if severity: + r.add(severity=severity, check_id="C-test", message="issue") + return r + + def test_strict_cli_exit_code_clean_success(self): + assert consistency_exit_code([self._report()]) == 0 + + def test_strict_cli_exit_code_warning_only_is_two(self): + assert consistency_exit_code([self._report("WARN")]) == 2 + + def test_strict_cli_exit_code_failure_is_one(self): + assert consistency_exit_code([self._report("FAIL")]) == 1 + + def test_remote_all_treats_warning_only_as_success(self): + assert consistency_exit_code([self._report("WARN")], remote_all=True) == 0 + + +class TestConsistencyMakeTargets: + CONSISTENCY_TARGETS = [ + ("check-consistency", ["REPO=state-hub"]), + ("fix-consistency", ["REPO=state-hub"]), + ("fix-consistency-remote", []), + ("check-consistency-here", []), + ("fix-consistency-here", []), + ("check-consistency-all", []), + ("fix-consistency-all", []), + ] + + def _fake_uv(self, tmp_path: Path) -> Path: + fake_uv = tmp_path / "uv" + fake_uv.write_text('#!/bin/sh\nexit "${FAKE_UV_EXIT:-0}"\n', encoding="utf-8") + fake_uv.chmod(0o755) + return fake_uv + + def _run_make(self, tmp_path: Path, target: str, args: list[str], uv_exit: int): + if shutil.which("make") is None: + pytest.skip("make is not installed") + repo_root = Path(__file__).resolve().parent.parent + env = os.environ.copy() + env["FAKE_UV_EXIT"] = str(uv_exit) + return subprocess.run( + [ + "make", + "--no-print-directory", + "-f", + "Makefile", + target, + *args, + f"UV={self._fake_uv(tmp_path)}", + ], + cwd=repo_root, + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_makefile_uv_resolver_checks_local_bin_for_non_login_shells(self): + repo_root = Path(__file__).resolve().parent.parent + makefile = (repo_root / "Makefile").read_text(encoding="utf-8") + assert "UV ?=" in makefile + assert "$$HOME/.local/bin/uv" in makefile + + @pytest.mark.parametrize(("target", "args"), CONSISTENCY_TARGETS) + def test_consistency_targets_treat_warning_exit_as_success(self, tmp_path, target, args): + result = self._run_make(tmp_path, target, args, uv_exit=2) + assert result.returncode == 0, result.stdout + result.stderr + + def test_fix_consistency_target_treats_clean_exit_as_success(self, tmp_path): + result = self._run_make(tmp_path, "fix-consistency", ["REPO=state-hub"], uv_exit=0) + assert result.returncode == 0, result.stdout + result.stderr + + def test_fix_consistency_target_keeps_failure_non_zero(self, tmp_path): + result = self._run_make(tmp_path, "fix-consistency", ["REPO=state-hub"], uv_exit=1) + assert result.returncode != 0 + + # --------------------------------------------------------------------------- # Status vocabulary normalisation # --------------------------------------------------------------------------- diff --git a/workplans/STATE-WP-0060-fix-consistency-cross-flavor.md b/workplans/STATE-WP-0060-fix-consistency-cross-flavor.md index 7e99b3e..a8382a5 100644 --- a/workplans/STATE-WP-0060-fix-consistency-cross-flavor.md +++ b/workplans/STATE-WP-0060-fix-consistency-cross-flavor.md @@ -4,7 +4,7 @@ type: workplan title: "Fix cross-flavor make fix-consistency failures" domain: custodian repo: state-hub -status: ready +status: finished owner: codex topic_slug: custodian created: "2026-06-07" @@ -57,7 +57,7 @@ still exit 0 because the Make target normalizes warning exit code `2`.) ```task id: STATE-WP-0060-T01 -status: todo +status: done priority: high state_hub_task_id: "011a49ad-13a5-46f7-849d-f7b1a0bca005" ``` @@ -85,7 +85,7 @@ Makefile. ```task id: STATE-WP-0060-T02 -status: todo +status: done priority: high state_hub_task_id: "49388ab7-db45-4bdb-a89e-bb7f116afd47" ``` @@ -110,7 +110,7 @@ Implementation notes: ```task id: STATE-WP-0060-T03 -status: todo +status: done priority: medium state_hub_task_id: "c9939dcb-37da-4073-a5f6-06f94fc7807e" ``` @@ -132,3 +132,32 @@ After workplan updates, run from `~/state-hub`: ```bash make fix-consistency REPO=state-hub ``` + +## Verification Notes + +Completed 2026-06-07: + +- Reproduced the local non-zero path in a non-login WSL shell: + `wsl -d Ubuntu-24.04 --cd /home/worsch/state-hub make fix-consistency + REPO=state-hub` failed with `/bin/sh: 1: uv: not found` before the checker + could apply warning/failure exit semantics. The same command worked through + `bash -lc`, where `uv` is on PATH as `/home/worsch/.local/bin/uv`. +- Confirmed the current Makefile line number is not `227`: the single-repo + `fix-consistency` target is now around line 240, so the older captured + `Makefile:227` reference is from a previous file version. +- Kept direct `scripts/consistency_check.py` strictness: clean exits `0`, + warnings-only exits `2`, and failures exit `1`. Added + `consistency_exit_code()` to make that contract explicit. +- Hardened all Makefile `uv` invocations with `UV ?= ...` so non-login shells + fall back to `~/.local/bin/uv`. Agent/operator consistency Make targets still + normalize warning-only `2` to shell success while preserving warning output. +- Added tests for strict checker exit codes and actual Make targets using a + fake `uv` executable: clean returns success, warning-only returns success for + Make wrappers, and real failures remain non-zero. + +Verification: + +- `.venv/bin/python -m pytest tests/test_consistency_check.py -q` -> 109 passed +- `wsl -d Ubuntu-24.04 --cd /home/worsch/state-hub make check-consistency + REPO=state-hub` -> PASS without a login shell +- `git diff --check` -> clean