From f75b999ae236b4fd80fce35c5e53a2752a7e5fdd Mon Sep 17 00:00:00 2001 From: Daniel Ari Friedman Date: Thu, 8 Oct 2026 15:54:35 -0700 Subject: [PATCH 1/2] Add release_check --bump and sidecar coverage (TOOL-6, #131) --bump X.Y.Z --date YYYY-MM-DD rewrites every gated file and prints the uv lock / CHANGELOG steps. .aii/config.yaml is now a checked version/date source; its stale 'v1.6.0 candidate ... unpublished' text is corrected. Publishing workflow deferred (maintainer action). --- .aii/config.yaml | 4 +- docs/release.md | 20 +++++-- docs/release_check.py | 103 ++++++++++++++++++++++++++++++++++++ tests/test_release_check.py | 76 ++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) diff --git a/.aii/config.yaml b/.aii/config.yaml index 093dec3..885d2b9 100644 --- a/.aii/config.yaml +++ b/.aii/config.yaml @@ -5,9 +5,7 @@ repo: name: fep_lean slug: fep-lean full_name: ActiveInferenceInstitute/fep_formal - description: '> **Release v1.6.0 candidate** (2026-10-07; prepared locally, unpublished) · Concept DOI [10.5281/zenodo.19699233](https://doi.org/10.5281/zenodo.19699233) - · License [CC BY 4.0](LICENSE) > > **Cite as:** Friedman, D. A. (2026). *Towards - Lean 4 Formalization of the Free Energy Principle:…' + description: 'Release v1.6.0 (2026-10-07; tagged v1.6.0) · Concept DOI [10.5281/zenodo.19699233](https://doi.org/10.5281/zenodo.19699233) · License CC BY 4.0. Standalone 168-topic Free Energy Principle / Active Inference Lean 4 catalogue.' institute: category: research-software steward: Active Inference Institute diff --git a/docs/release.md b/docs/release.md index b6b84c9..61b7ffa 100644 --- a/docs/release.md +++ b/docs/release.md @@ -9,8 +9,8 @@ actually ships, and it is fully checked by | Requirement | Checked by | | --- | --- | -| One version across `pyproject.toml`, `src/fep_lean/__init__.py`, `CITATION.cff`, `manuscript/config.yaml`, `config/settings.yaml`, `uv.lock` and the release-bundle constants | `release_check.py` | -| One release date across `CITATION.cff`, `manuscript/config.yaml` and the release-bundle constants | `release_check.py` | +| One version across `pyproject.toml`, `src/fep_lean/__init__.py`, `CITATION.cff`, `manuscript/config.yaml`, `config/settings.yaml`, `.aii/config.yaml`, `uv.lock` and the release-bundle constants | `release_check.py` | +| One release date across `CITATION.cff`, `manuscript/config.yaml`, `.aii/config.yaml` and the release-bundle constants | `release_check.py` | | A `## X.Y.Z — YYYY-MM-DD — title` section in `CHANGELOG.md` | `release_check.py` | | Clean working tree; `HEAD` equals `origin/main` | `release_check.py --hosted` | | The newest hosted `ci.yml` run on that exact commit succeeded, and its `python`, `lean`, `render-deps`, `render` and every `distribution` matrix job each succeeded (none skipped) | `release_check.py --hosted` | @@ -31,8 +31,14 @@ A release changes no scientific claim. Unrun lanes stay described as unrun. ## Steps -1. Bump the version and date in every file listed above, add the dated - `CHANGELOG.md` section, and run `uv lock`. +1. Bump with + `uv run python docs/release_check.py --bump X.Y.Z --date YYYY-MM-DD`. It + rewrites the version and date in every file listed above plus the + InstituteOS sidecar (`.aii/config.yaml`: `meta.updated` and the + `Release vX.Y.Z (date; ...)` description, which must be reworded by hand if + the release is not yet tagged). Then add the dated `CHANGELOG.md` section + and run the printed `uv lock` (the bump never touches `uv.lock` or the + changelog). 2. `uv run python docs/release_check.py` — fix every reported mismatch. 3. Run the local suite and audits (see [development](development.md)), commit and push to `main`. @@ -75,3 +81,9 @@ citation. The manuscript DOI identifies its exact scholarly version under the scholarly concept. For v1.6.0 the reserved version is `10.5281/zenodo.23220027`. Keep the scholarly concept, scholarly version and software concept distinct; do not describe the two histories as one version chain. + +## Deferred + +Publication is a maintainer action: there is intentionally no publishing +workflow. Automating tag, GitHub release and Zenodo upload is deferred until a +maintainer decides to delegate that authority. diff --git a/docs/release_check.py b/docs/release_check.py index cfb3aae..d677d56 100644 --- a/docs/release_check.py +++ b/docs/release_check.py @@ -27,6 +27,9 @@ #: conclusion alone is not enough. REQUIRED_JOBS = ("python", "lean", "render-deps", "render") DISTRIBUTION_PREFIX = "distribution (" +#: InstituteOS sidecar: ``repo.description`` names ``Release vX.Y.Z`` and the +#: date; ``meta.updated`` carries the release date. +SIDECAR = ".aii/config.yaml" @dataclass(frozen=True) @@ -66,6 +69,14 @@ def _lock_version(root: Path) -> str | None: return versions[0] if len(versions) == 1 else None +def _sidecar_release(root: Path) -> str | None: + """The release version the InstituteOS sidecar description names.""" + repo = _yaml(root, SIDECAR).get("repo") + description = str(repo.get("description", "")) if isinstance(repo, dict) else "" + match = re.search(r"Release v(\d+\.\d+\.\d+\S*)", description) + return match.group(1) if match else None + + def read_versions(root: Path) -> dict[str, str | None]: """Every version-bearing source, keyed by the file a maintainer edits.""" pyproject = tomllib.loads(_text(root, "pyproject.toml")) @@ -86,6 +97,7 @@ def read_versions(root: Path) -> dict[str, str | None]: "config/settings.yaml": _nested( _yaml(root, "config/settings.yaml"), "project", "version" ), + SIDECAR: _sidecar_release(root), "uv.lock": _lock_version(root), } @@ -101,6 +113,7 @@ def read_dates(root: Path) -> dict[str, str | None]: "src/fep_lean/output/release_bundle/_constants.py", "_CANONICAL_RELEASE_DATE", ), + SIDECAR: _nested(_yaml(root, SIDECAR), "meta", "updated"), } @@ -111,6 +124,72 @@ def read_release(root: Path) -> Release: ) +_VERSION_RE = re.compile(r"\d+\.\d+\.\d+") +_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}") +_CONSTANTS = "src/fep_lean/output/release_bundle/_constants.py" + + +def _sub_once(text: str, pattern: str, replacement: str) -> str: + new, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise ValueError(f"pattern not found: {pattern}") + return new + + +def bump(root: Path, version: str, date: str) -> tuple[str, ...]: + """Rewrite the version and date in every file the gate reads. + + Edits are line-local regex substitutions so quoting and comments survive. + ``uv.lock`` and ``CHANGELOG.md`` are deliberately left to the maintainer + (``uv lock``; a dated section); returns the files rewritten. + """ + if not _VERSION_RE.fullmatch(version): + raise ValueError(f"version must look like X.Y.Z, got {version!r}") + if not _DATE_RE.fullmatch(date): + raise ValueError(f"date must look like YYYY-MM-DD, got {date!r}") + qv, qd = f'"{version}"', f'"{date}"' + edits: dict[str, list[tuple[str, str]]] = { + "pyproject.toml": [(r'^version\s*=\s*"[^"]+"', f"version = {qv}")], + "src/fep_lean/__init__.py": [ + (r'^__version__\s*=\s*"[^"]+"', f"__version__ = {qv}") + ], + _CONSTANTS: [ + ( + r'^_CANONICAL_RELEASE_VERSION\s*=\s*"[^"]+"', + f"_CANONICAL_RELEASE_VERSION = {qv}", + ), + ( + r'^_CANONICAL_RELEASE_DATE\s*=\s*"[^"]+"', + f"_CANONICAL_RELEASE_DATE = {qd}", + ), + ], + "CITATION.cff": [ + (r"^version:.*$", f"version: {qv}"), + (r"^date-released:.*$", f"date-released: {qd}"), + ], + "manuscript/config.yaml": [ + (r"^( version:\s*).*$", rf"\g<1>{qv}"), + (r"^( date:\s*).*$", rf"\g<1>{qd}"), + ], + "config/settings.yaml": [(r"^( version:\s*).*$", rf"\g<1>{qv}")], + SIDECAR: [ + (r"Release v\d+\.\d+\.\d+\S*", f"Release v{version}"), + (r"(?<=\()\d{4}-\d{2}-\d{2}", date), + (r"^( updated:\s*).*$", rf"\g<1>'{date}'"), + ], + } + # Compute everything first so a failed pattern leaves the tree untouched. + rewritten: dict[str, str] = {} + for relative, subs in edits.items(): + text = _text(root, relative) + for pattern, replacement in subs: + text = _sub_once(text, pattern, replacement) + rewritten[relative] = text + for relative, text in rewritten.items(): + (root / relative).write_text(text, encoding="utf-8") + return tuple(rewritten) + + def _heading(release: Release) -> str: return f"## {release.version} — {release.date}" @@ -223,10 +302,34 @@ def main(argv: list[str] | None = None) -> int: action="store_true", help="also require a clean HEAD equal to origin/main with green hosted CI", ) + parser.add_argument( + "--bump", + metavar="X.Y.Z", + help="rewrite the version in every gated file (requires --date), then exit", + ) + parser.add_argument("--date", metavar="YYYY-MM-DD", help="release date for --bump") parser.add_argument("--notes", type=Path, help="write the release notes here") parser.add_argument("--json", action="store_true", help="print a JSON report") args = parser.parse_args(argv) root = args.root.resolve() + if args.bump or args.date: + if not (args.bump and args.date): + parser.error("--bump and --date must be given together") + try: + changed = bump(root, args.bump, args.date) + except ValueError as exc: + print(f"FAIL: {exc}", file=sys.stderr) + return 1 + print(f"Bumped {len(changed)} files to {args.bump} ({args.date}):") + for relative in changed: + print(f" {relative}") + print("Remaining manual steps:") + print( + f" 1. add a '## {args.bump} — {args.date} — title' section to CHANGELOG.md" + ) + print(" 2. uv lock # refreshes the fep-lean entry in uv.lock") + print(" 3. uv run python docs/release_check.py") + return 0 release = read_release(root) errors = list(metadata_errors(root)) if args.hosted: diff --git a/tests/test_release_check.py b/tests/test_release_check.py index 82aa252..fa7f53b 100644 --- a/tests/test_release_check.py +++ b/tests/test_release_check.py @@ -19,6 +19,7 @@ "CITATION.cff", "manuscript/config.yaml", "config/settings.yaml", + ".aii/config.yaml", "uv.lock", "CHANGELOG.md", ) @@ -64,6 +65,8 @@ def test_repository_metadata_agrees_on_one_release() -> None: ("CITATION.cff", "date-released: ", "date-released: 1999-01-01 #", "CITATION"), ("manuscript/config.yaml", " version: ", " version: 0.0.1 #", "manuscript"), ("config/settings.yaml", " version: ", " version: 0.0.1 #", "settings"), + (".aii/config.yaml", "Release v", "Release v0.0.", ".aii"), + (".aii/config.yaml", "updated: '", "updated: '1999-01-01' #", ".aii"), ], ) def test_any_metadata_drift_is_reported( @@ -158,3 +161,76 @@ def test_cli_reports_json_and_exit_status( root = _copy_sources(tmp_path) (root / "CITATION.cff").write_text("version: 0\n", encoding="utf-8") assert check.main(["--root", str(root)]) == 1 + + +def test_bump_rewrites_every_gated_file_and_leaves_only_expected_gaps( + tmp_path: Path, +) -> None: + check = _module() + root = _copy_sources(tmp_path) + changed = check.bump(root, "9.8.7", "2031-02-03") + assert ".aii/config.yaml" in changed and "pyproject.toml" in changed + versions, dates = check.read_versions(root), check.read_dates(root) + assert {k: v for k, v in versions.items() if k != "uv.lock"} == { + k: "9.8.7" for k in versions if k != "uv.lock" + } + assert set(dates.values()) == {"2031-02-03"} + errors = check.metadata_errors(root) + # Only the maintainer-owned steps remain: uv.lock and the changelog section. + assert len(errors) == 2 + assert any(e.startswith("uv.lock version") for e in errors) + assert any("CHANGELOG.md lacks" in e and "9.8.7" in e for e in errors) + + +def test_bump_is_idempotent_and_closes_the_gate_given_lock_and_changelog( + tmp_path: Path, +) -> None: + check = _module() + root = _copy_sources(tmp_path) + old = check.read_release(root) + check.bump(root, "9.8.7", "2031-02-03") + first = {r: (root / r).read_text(encoding="utf-8") for r in _SOURCES} + check.bump(root, "9.8.7", "2031-02-03") + assert first == {r: (root / r).read_text(encoding="utf-8") for r in _SOURCES} + lock = root / "uv.lock" + lock.write_text( + lock.read_text(encoding="utf-8").replace( + f'version = "{old.version}"', 'version = "9.8.7"', 1 + ), + encoding="utf-8", + ) + changelog = root / "CHANGELOG.md" + changelog.write_text( + "## 9.8.7 — 2031-02-03 — test\n\n" + changelog.read_text(encoding="utf-8"), + encoding="utf-8", + ) + assert check.metadata_errors(root) == () + + +@pytest.mark.parametrize( + ("version", "date"), [("1.7", "2031-02-03"), ("1.7.0", "03/02/2031")] +) +def test_bump_rejects_malformed_arguments_without_touching_files( + tmp_path: Path, version: str, date: str +) -> None: + check = _module() + root = _copy_sources(tmp_path) + before = {r: (root / r).read_text(encoding="utf-8") for r in _SOURCES} + with pytest.raises(ValueError): + check.bump(root, version, date) + assert before == {r: (root / r).read_text(encoding="utf-8") for r in _SOURCES} + + +def test_cli_bump_prints_uv_lock_and_requires_both_flags( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + check = _module() + root = _copy_sources(tmp_path) + assert ( + check.main(["--root", str(root), "--bump", "9.8.7", "--date", "2031-02-03"]) + == 0 + ) + assert "uv lock" in capsys.readouterr().out + assert check.read_release(root).version == "9.8.7" + with pytest.raises(SystemExit): + check.main(["--root", str(root), "--bump", "9.8.8"]) From 1f591c9f7beea851c76415f4fe1fc58daab90867 Mon Sep 17 00:00:00 2001 From: Daniel Ari Friedman Date: Thu, 8 Oct 2026 16:06:22 -0700 Subject: [PATCH 2/2] Make AGENTS.md the single owner of the required-check list (TOOL-4 #129, DOC-2 #139) AGENTS.md now lists every gate ci.yml runs (adds build_manuscript_figures, git diff --check, publication-browser-acceptance, openai-math-methods lint, native lean commands). docs/development.md and docs/testing.md link to it instead of re-listing; README pointer fixed (the 'Required release gates' heading never existed). tests/test_check_inventory.py asserts both directions against ci.yml with commented allow-lists, and that the secondary docs carry no divergent copy. --- AGENTS.md | 29 +++++ README.md | 5 +- docs/development.md | 40 ++----- docs/testing.md | 48 ++------- tests/test_check_inventory.py | 192 ++++++++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+), 76 deletions(-) create mode 100644 tests/test_check_inventory.py diff --git a/AGENTS.md b/AGENTS.md index e460628..7d35eef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,13 @@ promote the semantic disposition or prove the full FEP concept. ## Required checks +This block is the single owner of the required-check list. `docs/development.md`, +`docs/testing.md` and the README link here instead of re-listing it, and +`tests/test_check_inventory.py` fails if a command below is neither executed in +`.github/workflows/ci.yml` nor on that test's commented hosted-only/local-only +allow-lists, or if CI runs a gate that is missing here. Add a check to CI and +to this block in the same change. + ```bash uv lock --check && uv pip check uv run python scripts/_maint_build_topics_catalogue.py --check @@ -114,6 +121,8 @@ uv run fep-lean --project-root . methods check --output-root docs/mathematical-p uv run pytest specs/openai-math-methods/test_methods.py \ specs/openai-math-methods/test_upstream.py \ specs/openai-math-methods/test_visualization.py -q --no-cov +uv run ruff check specs/openai-math-methods +uv run ruff format --check specs/openai-math-methods uv run python scripts/audit_formalisms.py \ --receipt output/formalism-audit.json uv run python docs/theorem_ref_audit.py @@ -122,10 +131,17 @@ uv run fep-lean catalogue uv run python scripts/render_manuscript.py --check uv run python scripts/build_render_fonts.py --check uv run python scripts/check_render_log.py --verify-receipt +uv run python scripts/build_manuscript_figures.py +uv run python scripts/build_manuscript_figures.py --check +uv run pytest specs/publication-browser-acceptance/test_validate_acceptance.py -q --no-cov +uv run ruff check specs/publication-browser-acceptance +uv run ruff format --check specs/publication-browser-acceptance +uv run python specs/publication-browser-acceptance/validate_acceptance.py --collect output/python-acceptance-collection.json uv run pytest tests/ -q --cov=src --cov-fail-under=89 -m "not serial_lean" uv run mypy src uv run ruff check src tests scripts docs uv run ruff format --check src tests scripts docs +git diff --check uv run python docs/check_links.py --strict --include-root uv run python docs/md_hygiene.py --strict uv run python docs/pin_audit.py --check-latest @@ -135,6 +151,19 @@ uv run python specs/gnn-bridge-q5-artifact-proof/generate_probe.py --check uv run python specs/geo-infer-notation-bridge/check_geo_notation_bridge.py --check ``` +Native Lean checks need the built pinned Mathlib cache (`lake exe cache get`) +and run in the hosted `lean` job: + +```bash +(cd lean && lake build FepSketches) +uv run pytest tests/ -q -m serial_lean -n 1 --no-cov +uv run fep-lean verify --fail-on-warnings --receipt output/native-verification.json +``` + +Hosted CI also runs the `distribution` installed-wheel matrix, the isolated +live Chrome pair and the membership aggregate of +`validate_acceptance.py`; see `docs/testing.md` for those boundaries. + The listed Q5 `generate_probe.py --check` and GEO notation checks validate retained static artifacts without a sibling checkout. CI runs both. Q5 generation establishes fixture/probe/manifest freshness, not current diff --git a/README.md b/README.md index b455527..1cb4db6 100644 --- a/README.md +++ b/README.md @@ -269,8 +269,9 @@ The operator command quick reference lives at ## Development checks -The complete release-gate list is maintained as "Required release gates" in -[`docs/testing.md`](docs/testing.md); the quick local dev checks are: +The complete required-check list is maintained once, in the "Required checks" +block of [`AGENTS.md`](AGENTS.md#required-checks); the quick local dev checks +are: ```bash uv run pytest tests/ -q --cov=src --cov-fail-under=89 -m "not serial_lean" diff --git a/docs/development.md b/docs/development.md index 0a41683..2946118 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,42 +43,16 @@ uv run python scripts/theorem_maturity_audit.py uv run python scripts/build_formalism_coverage.py uv run fep-lean atlas uv run fep-lean dashboard - -uv run python scripts/_maint_build_topics_catalogue.py --check -uv run python scripts/_maint_build_fep_all_lean.py --check -uv run python scripts/_maint_build_formal_modules.py --check -uv run python scripts/theorem_maturity_audit.py --check -uv run python scripts/build_formalism_coverage.py --check -uv run python scripts/_maint_build_lean_landscape.py --check -uv run fep-lean atlas --check -uv run fep-lean dashboard --check -uv run python specs/geo-infer-notation-bridge/check_geo_notation_bridge.py --check -uv run python docs/theorem_ref_audit.py -uv run python docs/citation_audit.py -uv run fep-lean catalogue -uv run python scripts/render_manuscript.py --check - -uv run ruff check src tests scripts docs -uv run ruff format --check src tests scripts docs -uv run mypy src -uv run pytest tests/ -q --cov=src --cov-fail-under=89 -m "not serial_lean" -uv run python docs/check_links.py --strict --include-root -uv run python docs/md_hygiene.py --strict -uv run python docs/pin_audit.py --check-latest -uv run python docs/xref_audit.py ``` -Run native Lean acceptance separately because it is the expensive semantic -compiler boundary: +The required verification list (`--check` gates, audits, tests, lint, native +Lean) is owned by the "Required checks" block in +[`AGENTS.md`](../AGENTS.md#required-checks); it is not repeated here, and +`tests/test_check_inventory.py` keeps it in step with `.github/workflows/ci.yml`. -```bash -uv run fep-lean verify \ - --fail-on-warnings \ - --receipt output/native-verification.json -cd lean && lake build FepSketches -cd .. && uv run python scripts/audit_formalisms.py \ - --receipt output/formalism-audit.json -``` +Native Lean acceptance (`lake build FepSketches`, `fep-lean verify`, the +formalism audit) is the expensive semantic compiler boundary; its commands are in the same +[required-checks block](../AGENTS.md#required-checks). The native receipt establishes exact-source compilation for the stable topic roster; the formalism audit separately covers the maintained formal modules diff --git a/docs/testing.md b/docs/testing.md index f0f746a..b92985e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,8 +10,10 @@ The suite uses real temporary files, SQLite databases, subprocesses, and local HTTP servers. Environment variables isolate secrets and expensive external integration tests; they do not manufacture successful execution results. -Local checks are below; hosted `ci.yml` runs them, and a green hosted run on -the exact commit is the release gate (see the [release procedure](release.md)): +The full required-check list lives in one place, the "Required checks" block +of [`AGENTS.md`](../AGENTS.md#required-checks); it is not repeated here. +Hosted `ci.yml` runs it, and a green hosted run on the exact commit is the +release gate (see the [release procedure](release.md)). Hosted Python acceptance records the complete non-serial test selection, runs the remainder in parallel, then runs the two real Chrome probes once with no @@ -24,45 +26,9 @@ coverage are retained even when a component fails. This scheduling policy preserves the browser startup deadline and does not diagnose the historical startup timeouts. See the [acceptance validator](../specs/publication-browser-acceptance/README.md). -```bash -uv lock --check && uv pip check -uv run python scripts/_maint_build_topics_catalogue.py --check -uv run python scripts/_maint_build_fep_all_lean.py --check -uv run python scripts/_maint_build_formal_modules.py --check -uv run python scripts/theorem_maturity_audit.py --check -uv run python scripts/build_formalism_coverage.py --check -uv run python scripts/_maint_build_lean_landscape.py --check -uv run python scripts/build_formalism_atlas.py --check -uv run python scripts/build_formal_kernel_dashboard.py --check -uv run pytest tests/ -q --cov=src --cov-fail-under=89 -m "not serial_lean" -uv run mypy src -uv run ruff check src tests scripts docs -uv run ruff format --check src tests scripts docs -git diff --check -uv run python docs/check_links.py --strict --include-root -uv run python docs/md_hygiene.py --strict -uv run python docs/pin_audit.py --check-latest -uv run python docs/xref_audit.py -uv run python docs/theorem_ref_audit.py -uv run python docs/citation_audit.py -uv run fep-lean catalogue -uv run python scripts/build_render_fonts.py --check -uv run python scripts/check_render_log.py --verify-receipt -uv run python scripts/render_manuscript.py --check -uv run python scripts/build_manuscript_figures.py -uv run python scripts/build_manuscript_figures.py --check -``` - -Native formal acceptance additionally requires the pinned toolchain and -Mathlib build: - -```bash -cd lean && lake build FepSketches -cd .. && uv run python scripts/audit_formalisms.py \ - --receipt output/formalism-audit.json -uv run fep-lean verify --fail-on-warnings \ - --receipt output/native-verification.json -``` +The native formal commands (`lake build FepSketches`, the formalism audit, +`fep-lean verify --fail-on-warnings --receipt ...`) require the pinned +toolchain and Mathlib build and are listed in the same block. Hermes/OpenGauss full validation additionally requires external credentials; `fep-lean preflight` reports each missing capability without modifying the diff --git a/tests/test_check_inventory.py b/tests/test_check_inventory.py new file mode 100644 index 0000000..27735b2 --- /dev/null +++ b/tests/test_check_inventory.py @@ -0,0 +1,192 @@ +"""The required-check list has one owner (AGENTS.md) and CI executes it.""" + +from __future__ import annotations + +import re +import shlex +from pathlib import Path + +import pytest +import yaml + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +AGENTS = PROJECT_ROOT / "AGENTS.md" +CI = PROJECT_ROOT / ".github" / "workflows" / "ci.yml" +SECONDARY_DOCS = ("docs/development.md", "docs/testing.md") + +#: AGENTS.md commands CI does not execute verbatim. Empty on purpose: a +#: required check that hosted CI never runs is a drifted contract. Add an entry +#: only with the reason a command is local-only. +LOCAL_ONLY: dict[str, str] = {} + +#: Commands CI runs that the "Required checks" block deliberately omits. +#: Normalised form -> why. Generation (non-``--check``) steps materialise +#: inputs for later gates; the rest are hosted-only orchestration whose +#: inputs (JUnit files, per-OS interpreters) do not exist on a workstation. +HOSTED_ONLY = { + "python scripts/build_formalism_atlas.py": "render-deps: generates the atlas", + "python scripts/build_formal_kernel_dashboard.py": "render-deps: generates it", + "fep-lean --project-root . methods export --output-root " + "docs/mathematical-positioning": "render-deps: generates the methods export", + "python scripts/build_render_fonts.py": "render: generates the font record", + "python scripts/render_publication.py": "render: the publication render itself", + "pytest tests/test_distribution.py -q -s --no-cov": "distribution matrix cell", + "pytest specs/openai-math-methods/test_distribution_methods.py -q -s --no-cov": ( + "distribution matrix cell" + ), + "pytest tests/test_browser_capture.py::" + "test_live_chrome_blocks_and_records_a_delayed_outbound_request " + "tests/test_browser_capture.py::" + "test_live_chrome_replay_terminates_every_profile_writer -q -n 0 --cov=src " + "--cov-append --cov-report=xml --cov-fail-under=89 " + "--junitxml=output/live-chrome-acceptance.xml": "isolated live Chrome component", + "python specs/publication-browser-acceptance/validate_acceptance.py " + "--collection output/python-acceptance-collection.json " + "--parallel-junit output/python-acceptance.xml " + "--chrome-junit output/live-chrome-acceptance.xml --coverage coverage.xml " + "--output output/python-acceptance-summary.json": "needs the hosted JUnit files", +} + +_TRACKED_PREFIXES = ("uv run", "lake ", "git diff", "uv lock", "uv pip") + + +def normalise(command: str) -> str: + """Canonical form: drop ``uv run [--locked]``, ``python -m``, redirections.""" + command = re.split(r"\s+(?:2>&1|\|)\s*", command, maxsplit=1)[0] + tokens = shlex.split(command.strip().strip("()").strip()) + if tokens[:2] == ["uv", "run"]: + tokens = tokens[2:] + if tokens and tokens[0] == "--locked": + tokens = tokens[1:] + if tokens[:3] == ["python", "-m", "pytest"]: + tokens = ["pytest", *tokens[3:]] + return " ".join(tokens) + + +def split_commands(script: str) -> list[str]: + """Split a shell snippet into commands, joining ``\\`` continuations. + + Comments, heredoc bodies and control flow are skipped; ``a && b`` is two + commands and ``(cd d && c)`` is ``c``. + """ + commands: list[str] = [] + heredoc_end: str | None = None + joined = re.sub(r"\\\n\s*", " ", script) + for raw in joined.splitlines(): + line = raw.strip() + if heredoc_end is not None: + if line == heredoc_end: + heredoc_end = None + continue + if not line or line.startswith("#"): + continue + heredoc = re.search(r"<<-?\s*'?(\w+)'?\s*$", line) + if heredoc: + heredoc_end = heredoc.group(1) + continue # inline python is not a repository command + line = line.strip("()") + for part in re.split(r"\s*&&\s*", line): + part = part.strip().strip("()").strip() + if part.startswith("cd "): + continue + if part.startswith(_TRACKED_PREFIXES): + commands.append(part) + return commands + + +def agents_commands() -> list[str]: + text = AGENTS.read_text(encoding="utf-8") + section = text.split("## Required checks", 1)[1].split("\n## ", 1)[0] + blocks = re.findall(r"```bash\n(.*?)```", section, flags=re.DOTALL) + assert blocks, "AGENTS.md 'Required checks' has no bash block" + return [normalise(c) for block in blocks for c in split_commands(block)] + + +def ci_commands() -> list[str]: + workflow = yaml.safe_load(CI.read_text(encoding="utf-8")) + commands: list[str] = [] + for job in workflow["jobs"].values(): + for step in job.get("steps", []): + if isinstance(step.get("run"), str): + commands += [normalise(c) for c in split_commands(step["run"])] + return commands + + +def _covers(ci: str, required: str) -> bool: + """CI runs ``required`` verbatim; pytest may add options (xdist, junit).""" + if ci == required: + return True + ci_tokens, required_tokens = ci.split(), required.split() + return ( + required_tokens[0] == ci_tokens[0] == "pytest" + and required_tokens[1] == ci_tokens[1] + and all(token in ci_tokens for token in required_tokens) + ) + + +def test_normaliser_handles_continuations_chains_and_prefixes() -> None: + script = ( + "uv lock --check && uv pip check\n" + "# comment\n" + "uv run --locked python -m pytest a.py \\\n -q --no-cov\n" + "(cd lean && lake build FepSketches) 2>&1 | tee log\n" + "uv run python - <<'PY'\nuv run python hidden.py\nPY\n" + ) + assert [normalise(c) for c in split_commands(script)] == [ + "uv lock --check", + "uv pip check", + "pytest a.py -q --no-cov", + "lake build FepSketches", + ] + + +def test_every_required_check_is_executed_by_ci_or_explicitly_local() -> None: + required, ci = agents_commands(), ci_commands() + assert len(required) > 25, "AGENTS.md required-check parse looks truncated" + missing = [ + command + for command in required + if command not in LOCAL_ONLY and not any(_covers(c, command) for c in ci) + ] + assert not missing, f"AGENTS.md checks that ci.yml never runs: {missing}" + + +def test_every_ci_gate_is_listed_in_agents_or_explicitly_hosted_only() -> None: + required = agents_commands() + unlisted = sorted( + { + command + for command in ci_commands() + if command not in HOSTED_ONLY + and not any(_covers(command, r) for r in required) + } + ) + assert not unlisted, ( + f"ci.yml gates missing from AGENTS.md 'Required checks': {unlisted}" + ) + + +def test_allow_lists_carry_no_dead_entries() -> None: + ci = set(ci_commands()) + stale = [c for c in HOSTED_ONLY if c not in ci] + assert not stale, f"HOSTED_ONLY entries CI no longer runs: {stale}" + agents = set(agents_commands()) + assert not [c for c in LOCAL_ONLY if c not in agents] + + +@pytest.mark.parametrize("relative", SECONDARY_DOCS) +def test_secondary_docs_link_to_the_owner_instead_of_copying_the_list( + relative: str, +) -> None: + text = (PROJECT_ROOT / relative).read_text(encoding="utf-8") + assert "AGENTS.md#required-checks" in text + required = set(agents_commands()) + for block in re.findall(r"```bash\n(.*?)```", text, flags=re.DOTALL): + shared = [c for c in split_commands(block) if normalise(c) in required] + assert len(shared) < 4, f"{relative} re-lists required checks: {shared}" + + +def test_readme_points_at_the_owner_and_no_dangling_heading() -> None: + readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + assert "AGENTS.md#required-checks" in readme + assert "Required release gates" not in readme