diff --git a/AGENTS.md b/AGENTS.md index a65813f3..d1efa405 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ uv run assembly --help # run the CLI from the locked environment Dev tooling is a PEP 735 `[dependency-groups]` group with `default-groups = ["dev"]`, not a `[project]` extra — `uv sync --extra dev` errors. -`scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → max-file-length (500 lines) → `xenon` (cyclomatic complexity, max grade B / project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `codespell` (spell-check code/comments/docs via `uvx`; config in `[tool.codespell]`) → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → unused snapshot/fixture gate (`scripts/unused_fixtures_gate.py`: orphaned `.ambr`/API fixtures, since xdist disables syrupy's own unused detection) → docs consistency gate (`scripts/docs_consistency_gate.py`: REFERENCE.md/README.md env vars, exit codes, and `assembly …` command refs stay in sync with the code) → docstring coverage gate (`scripts/docstring_coverage_gate.py`: public-API docstring ratchet, an `interrogate` stand-in that handles PEP 695 generics) → `brew audit --strict` (the shipped `Formula/assembly.rb`; self-skips without Homebrew) → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → **mutation gate** (diff-scoped: mutates each changed line and reruns the tests that cover it — a surviving mutant fails the gate, so changed lines need assertions that would *fail* if the line broke, not just coverage; suppress a genuinely unassertable line with `# pragma: no mutate`) → a "no new escape hatches" gate (`# type: ignore` / `# noqa` / `pragma: no cover` / `Any` / `cast(` / test skip/xfail/sleep, all **count-gated against the merge-base** so moving an existing hatch in a refactor doesn't false-positive but a net-new one fails) → **CodeQL gate** (`scripts/codeql_gate.py`: the same security + quality suites the CodeQL workflow uploads to GitHub's code-scanning/quality tabs, run locally over python/actions/javascript so alerts fail before push instead of on the PR; needs the CodeQL bundle on PATH — self-skips otherwise, `codeql.yml` covers CI, and the web session-start hook provisions it) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon`, patch-coverage, and mutation stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.` +`scripts/check.sh` is the authoritative gate; keep this list in sync with it. It runs, in order: `uv lock --check` → `ruff check` → `ruff format --check` → `mypy` → `pyright` (src strict) → `pyright` (tests) → `vulture` (dead code) → `deptry` (dependency hygiene) → `lint-imports` (import-linter architecture contracts) → max-file-length (500 lines) → `xenon` (cyclomatic complexity: function max B, module avg A, project avg A) → `swiftlint` + swift compile (macOS only, skipped elsewhere) → `markdownlint` → `codespell` (spell-check code/comments/docs via `uvx`; config in `[tool.codespell]`) → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → unused snapshot/fixture gate (`scripts/unused_fixtures_gate.py`: orphaned `.ambr`/API fixtures, since xdist disables syrupy's own unused detection) → docs consistency gate (`scripts/docs_consistency_gate.py`: REFERENCE.md/README.md env vars, exit codes, and `assembly …` command refs stay in sync with the code) → docstring coverage gate (`scripts/docstring_coverage_gate.py`: public-API docstring ratchet, an `interrogate` stand-in that handles PEP 695 generics) → `brew audit --strict` (the shipped `Formula/assembly.rb`; self-skips without Homebrew) → `pytest` (90% branch coverage) → `diff-cover` (100% patch coverage vs `origin/main`) → **mutation gate** (diff-scoped: mutates each changed line and reruns the tests that cover it — a surviving mutant fails the gate, so changed lines need assertions that would *fail* if the line broke, not just coverage; suppress a genuinely unassertable line with `# pragma: no mutate`) → a "no new escape hatches" gate (`# type: ignore` / `# noqa` / `pragma: no cover` / `Any` / `cast(` / test skip/xfail/sleep, all **count-gated against the merge-base** so moving an existing hatch in a refactor doesn't false-positive but a net-new one fails) → **CodeQL gate** (`scripts/codeql_gate.py`: the same security + quality suites the CodeQL workflow uploads to GitHub's code-scanning/quality tabs, run locally over python/actions/javascript so alerts fail before push instead of on the PR; needs the CodeQL bundle on PATH — self-skips otherwise, `codeql.yml` covers CI, and the web session-start hook provisions it) → `uv build` + `twine check --strict`. The `vulture`/`deptry`/`lint-imports`/`xenon`, patch-coverage, and mutation stages catch the failures that `ruff`+`mypy` alone won't — don't claim the gate is green until the script prints `All checks passed.` **Commits are gated.** On success `check.sh` records a working-tree signature (`scripts/gate_marker.py record` → `.git/aai-gate-pass`), and a PreToolUse hook (`.claude/hooks/require-gate-before-commit.sh`) blocks `git commit` unless that signature still matches — so run the full gate to completion *before* committing (a single-file `pytest` does not satisfy it), and re-run it after any further edit. Iterate with the fast targeted commands above, gate once at the end. For a deliberate work-in-progress commit, prefix `AAI_ALLOW_COMMIT=1 git commit …`. diff --git a/aai_cli/ui/update_check.py b/aai_cli/ui/update_check.py index 35e27ba9..3b88c13b 100644 --- a/aai_cli/ui/update_check.py +++ b/aai_cli/ui/update_check.py @@ -28,6 +28,11 @@ _CHECK_INTERVAL_SECONDS = 24 * 60 * 60 _FETCH_TIMEOUT_SECONDS = 5.0 _USER_AGENT = f"assembly-cli/{__version__}" +_HOMEBREW_PATH_MARKERS = ("/cellar/", "/homebrew/") +_UPGRADE_COMMAND_MARKERS = ( + ("pipx", "pipx upgrade aai-cli"), + ("/uv/tools/", "uv tool upgrade aai-cli"), +) def is_newer(latest: str, current: str) -> bool: @@ -38,19 +43,24 @@ def is_newer(latest: str, current: str) -> bool: return False +def _is_homebrew_executable(executable: str) -> bool: + if executable.startswith("/usr/local/"): + return True + return any(marker in executable for marker in _HOMEBREW_PATH_MARKERS) + + def detect_upgrade_command() -> str: """The exact upgrade command for the install method the running interpreter lives in, or "" when it can't be determined (callers show a docs hint).""" - exe = (sys.executable or "").lower() - if "/cellar/" in exe or "/homebrew/" in exe or exe.startswith("/usr/local/"): + executable = (sys.executable or "").lower() + if _is_homebrew_executable(executable): return "brew upgrade assembly" # pipx/uv track installs by *distribution* name (aai-cli), not the console # command (assembly) — "pipx upgrade assembly" fails with "not installed". - if "pipx" in exe: - return "pipx upgrade aai-cli" - if "/uv/tools/" in exe: - return "uv tool upgrade aai-cli" - return "" + return next( + (command for marker, command in _UPGRADE_COMMAND_MARKERS if marker in executable), + "", + ) def fetch_and_cache() -> None: @@ -116,6 +126,12 @@ def _render(current: str, latest: str) -> None: output.error_console.print(panel) +def _cache_is_stale(last_check: float | None, *, now: float) -> bool: + if last_check is None: + return True + return (now - last_check) > _CHECK_INTERVAL_SECONDS + + def maybe_notify(*, json_mode: bool) -> None: """Render the cached notice (if newer) and refresh the cache if stale. @@ -132,7 +148,8 @@ def _maybe_notify(*, json_mode: bool) -> None: if not _should_notify(json_mode=json_mode): return last_check, latest = config.get_update_cache() - if latest and is_newer(latest, __version__): + now = time.time() + if latest is not None and is_newer(latest, __version__): _render(__version__, latest) - if last_check is None or (time.time() - last_check) > _CHECK_INTERVAL_SECONDS: + if _cache_is_stale(last_check, now=now): spawn_refresh() diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 506e055c..b6429715 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -54,6 +54,7 @@ def test_is_newer(latest, current, expected): [ ("/opt/homebrew/Cellar/assembly/0.1.0/libexec/bin/python", "brew upgrade assembly"), ("/usr/local/Cellar/assembly/0.1.0/libexec/bin/python", "brew upgrade assembly"), + ("/usr/local/bin/python", "brew upgrade assembly"), # pipx/uv upgrade by *distribution* name (aai-cli), not the console command. ("/Users/x/.local/pipx/venvs/aai-cli/bin/python", "pipx upgrade aai-cli"), ("/Users/x/.local/share/uv/tools/aai-cli/bin/python", "uv tool upgrade aai-cli"), @@ -214,6 +215,11 @@ def test_maybe_notify_spawns_refresh_only_when_stale(tmp_path, monkeypatch): spawned: list[bool] = [] monkeypatch.setattr(update_check, "spawn_refresh", lambda: spawned.append(True)) + # Never checked -> spawn. + update_check.maybe_notify(json_mode=False) + assert spawned == [True] + spawned.clear() + # Fresh check -> no spawn. config.set_update_cache(last_check=time.time(), latest_version=None) update_check.maybe_notify(json_mode=False)