diff --git a/autoassistant/audit_skill_apis.py b/autoassistant/audit_skill_apis.py index ce02f78..1233ac7 100644 --- a/autoassistant/audit_skill_apis.py +++ b/autoassistant/audit_skill_apis.py @@ -774,7 +774,26 @@ def compute_baseline() -> dict: } -def write_baseline(root: Path) -> Path: +def write_baseline(root: Path, *, allow_dev_stack: bool = False) -> Path: + # The committed baseline is the contract "these docs were validated against this + # released stack". Nightly releases are wheels/tags-only, so a source checkout / + # PYTHONPATH / editable stack reports the *last-committed* version string, not the + # latest release — a baseline snapshotted from one silently pins a version PyPI has + # moved past (this exact incident shipped once and was caught by the wiki-currency + # CI). Refuse unless the caller explicitly owns the risk. + if not allow_dev_stack: + check = inspect_installation() + if check.install_kind not in ( + "packaged install (pip/conda provenance not distinguishable)", + ): + sys.exit( + f"[baseline] refusing to write: the active stack is a dev install " + f"({check.install_kind}), whose version string can trail the latest " + f"release (wheels/tags-only nightlies never bump source `main`). " + f"Write the baseline from a clean venv with the released wheel " + f"(`env -u PYTHONPATH /bin/python ... --write-baseline`), or " + f"pass --allow-dev-stack to snapshot this stack deliberately." + ) baseline = compute_baseline() path = root / BASELINE_REL_PATH path.parent.mkdir(parents=True, exist_ok=True) @@ -1378,7 +1397,14 @@ def main() -> int: "--write-baseline", action="store_true", help="Snapshot the installed stack (versions + API-surface hash) to " - "wiki/core/api_audit_baseline.json and exit. Re-pin after a deliberate upgrade.", + "wiki/core/api_audit_baseline.json and exit. Re-pin after a deliberate upgrade. " + "Refuses a dev-source stack unless --allow-dev-stack is passed.", + ) + parser.add_argument( + "--allow-dev-stack", + action="store_true", + help="Permit --write-baseline against a source-checkout/PYTHONPATH/editable " + "stack, whose version string can trail the latest PyPI release.", ) parser.add_argument( "--check-version", @@ -1472,7 +1498,7 @@ def main() -> int: return install_status return check_version(root) if args.write_baseline: - path = write_baseline(root) + path = write_baseline(root, allow_dev_stack=args.allow_dev_stack) print(f"[baseline] wrote {path}", file=sys.stderr) return 0 diff --git a/autoassistant/tests/test_install_preflight.py b/autoassistant/tests/test_install_preflight.py index e6c4d7e..4611ed1 100644 --- a/autoassistant/tests/test_install_preflight.py +++ b/autoassistant/tests/test_install_preflight.py @@ -75,3 +75,35 @@ def test_version_check_routes_absent_stack_to_install_preflight(): assert process.returncode == 2 assert "[install] NOT INSTALLED" in process.stderr assert "API DRIFT" not in process.stderr + + +def test_write_baseline_refuses_dev_stack(tmp_path, monkeypatch): + """The baseline contract is 'validated against this RELEASED stack'; a dev-source + stack's version string can trail PyPI (wheels/tags-only nightlies), so snapshotting + one without --allow-dev-stack must refuse.""" + import importlib.util + import sys as _sys + + spec = importlib.util.spec_from_file_location( + "audit_under_test_baseline", ROOT / "autoassistant" / "audit_skill_apis.py" + ) + mod = importlib.util.module_from_spec(spec) + _sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + + dev_check = mod.InstallationCheck( + status="ready", python="py", prefix="env", versions={}, locations={}, + missing=[], errors={}, install_kind="source checkout or PYTHONPATH install", + cache_defaults={}, + ) + monkeypatch.setattr(mod, "inspect_installation", lambda: dev_check) + + import pytest as _pytest + + with _pytest.raises(SystemExit) as exc: + mod.write_baseline(tmp_path) + assert "refusing to write" in str(exc.value) + + # With the explicit flag the same stack is snapshotted deliberately. + path = mod.write_baseline(tmp_path, allow_dev_stack=True) + assert path.exists() diff --git a/skills/af_audit_skill_apis.md b/skills/af_audit_skill_apis.md index 0b235bd..323160a 100644 --- a/skills/af_audit_skill_apis.md +++ b/skills/af_audit_skill_apis.md @@ -92,6 +92,13 @@ python autoassistant/audit_skill_apis.py --check-version python autoassistant/audit_skill_apis.py --write-baseline ``` +`--write-baseline` **refuses a dev-source stack** (source checkout / PYTHONPATH / +editable): nightly releases are wheels/tags-only, so a dev stack's version string can +trail the latest PyPI release and would bake a stale pin into the baseline. Write the +baseline from a clean venv holding the released wheel +(`env -u PYTHONPATH /bin/python autoassistant/audit_skill_apis.py --write-baseline`); +`--allow-dev-stack` overrides deliberately. + The workflow is: `--check-version` flags that the installed autofit moved → run the full `--scope all` audit to find what broke → fix the references (per the Branch section below) → `--write-baseline` to re-pin once the audit is clean. **Only re-pin after fixing**, never