Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 …`.

Expand Down
35 changes: 26 additions & 9 deletions aai_cli/ui/update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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()
6 changes: 6 additions & 0 deletions tests/test_update_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down
Loading