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
50 changes: 50 additions & 0 deletions .claude/hooks/require-gate-before-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# PreToolUse(Bash) gate: block `git commit` unless ./scripts/check.sh recorded a
# passing run for the *current* working tree. The full gate (mutation + 100% patch
# coverage tail) is what makes a commit safe here; a single-file `pytest` does not.
#
# Escape hatch for deliberate work-in-progress commits (e.g. before a multi-agent
# workflow, per the commit-wip-before-workflows practice): prefix the command with
# AAI_ALLOW_COMMIT=1 git commit ...
set -euo pipefail

input="$(cat)"
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"

# Only police git commits; let everything else through immediately.
if ! printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+commit([[:space:]]|$)'; then
exit 0
fi

# Explicit opt-out for intentional WIP commits.
if printf '%s' "$cmd" | grep -q 'AAI_ALLOW_COMMIT=1'; then
exit 0
fi

# Fail open outside this repo or if the marker tool is missing, so the hook never
# wedges commits in an unrelated checkout.
root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$root" ] || [ ! -f "$root/scripts/gate_marker.py" ]; then
exit 0
fi

if python3 "$root/scripts/gate_marker.py" check >/dev/null 2>&1; then
exit 0
fi

cat >&2 <<'MSG'
Blocked: ./scripts/check.sh has not passed for the current working tree.

The full gate (incl. the mutation + 100% patch-coverage tail) must be green before
committing — a single-file `pytest` does not satisfy it. Run:

./scripts/check.sh

then commit again once it prints "All checks passed." Editing any file after the
gate passes re-requires it (the marker is keyed to the exact tree contents).

To commit work-in-progress on purpose, prefix the command:

AAI_ALLOW_COMMIT=1 git commit ...
MSG
exit 2
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *uv.lock|*.env|*/.env) echo \"Blocked: $f is a protected lock/secret file. Edit it by hand if you really intend to.\" >&2; exit 2;; esac; exit 0"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/require-gate-before-commit.sh"
}
]
}
],
"PostToolUse": [
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Dev tooling is a PEP 735 `[dependency-groups]` group with `default-groups = ["de

`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` → `prettier` (init template JS/CSS) → `shellcheck` → `actionlint` + `zizmor` (workflow lint/audit) → `gitleaks` (secret scan) → generated `--show-code` compile gate → init template contract gate → `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" diff gate (`# type: ignore` / `# noqa` / `pragma: no cover` / net-new `Any` / `cast(`) → `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 …`.

Individual tools (all via `uv run`):

```sh
Expand Down Expand Up @@ -152,6 +154,7 @@ the return value comes from a recorded payload instead of a hand-built mock.
- The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `assembly` (`[project.scripts] assembly = "aai_cli.main:run"`).
- `assembly init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`).
- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.12–3.13.
- **Releasing is tag-triggered.** `.github/workflows/release.yml` fires on a pushed `vX.Y.Z` tag and builds the prebuilt arm64 Homebrew bottle (`Formula/assembly.rb`), cuts the GitHub Release, and opens the formula PR — bottling matters because the deps include Rust-backed sdists (`pydantic-core`, `jiter`, `cryptography`) that would otherwise compile from source on `brew install`. Two committed helpers drive it and are self-documenting (`--help`): `scripts/bump_minor.sh` rewrites the version in lock-step across `pyproject.toml` + `aai_cli/__init__.py` (run on a branch → merge the PR), then `scripts/cut_release.sh` tags + pushes. **`cut_release.sh` only runs from a clean `main` in sync with `origin/main`** (it hard-errors on a feature branch / dirty tree / version mismatch), so cut releases from `main`, not your working branch. The "update available" notice users see is `aai_cli/update_check.py`.

## Architecture

Expand Down
8 changes: 7 additions & 1 deletion scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ if command -v shellcheck >/dev/null 2>&1; then
# (paths resolve from the repo root, where this script always runs).
shellcheck -x --source-path=. scripts/check.sh scripts/docker_build_check.sh \
scripts/cut_release.sh scripts/bump_minor.sh scripts/gate_tool_pins.sh \
.claude/hooks/session-start.sh
.claude/hooks/session-start.sh .claude/hooks/require-gate-before-commit.sh
else
echo " shellcheck not found; skipping (CI runs it)"
fi
Expand Down Expand Up @@ -258,4 +258,10 @@ rm -rf dist
uv build
uvx twine check --strict dist/*

# Record that this exact working tree passed, so the pre-commit gate hook
# (.claude/hooks/require-gate-before-commit.sh) can let a `git commit` through.
# Any later edit changes the signature and re-requires a green gate. Never fail
# the gate over the marker itself.
python3 scripts/gate_marker.py record || true

echo "All checks passed."
136 changes: 136 additions & 0 deletions scripts/gate_marker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Record / verify that ``scripts/check.sh`` passed for the *current* working tree.

``check.sh`` calls ``record`` on success, writing a signature of the working tree
into the repo's git dir. The pre-commit gate hook
(``.claude/hooks/require-gate-before-commit.sh``) calls ``check``: it recomputes the
signature and blocks ``git commit`` unless it matches — so any edit made *after* the
gate passed invalidates the marker and re-requires a green run.

stdlib-only on purpose (runs from a hook and from check.sh, before/without ``uv``).
"""

from __future__ import annotations

import hashlib
import subprocess
import sys
from pathlib import Path
from typing import Protocol


class _Digest(Protocol):
def update(self, data: bytes, /) -> None: ...


MARKER_NAME = "aai-gate-pass"
# Hash full content for normal source files; for anything larger (committed audio
# fixtures, build artifacts) fall back to size+mtime so a commit check stays fast.
_CONTENT_HASH_MAX_BYTES = 2 * 1024 * 1024
# `git status --porcelain=v1 -z` records are "XY<space><path>" — 3-byte prefix.
_STATUS_PREFIX_LEN = 3
# argv when invoked as `gate_marker.py <action>`.
_ARGC_WITH_ACTION = 2


def _git(*args: str) -> bytes:
# S603/S607 are ignored project-wide (the CLI shells out to controlled tools);
# args here are all literals, never user input.
return subprocess.run(["git", *args], check=True, capture_output=True).stdout


def _marker_path() -> Path:
git_dir = _git("rev-parse", "--git-dir").decode().strip()
return Path(git_dir) / MARKER_NAME


def _head() -> bytes:
try:
return _git("rev-parse", "HEAD").strip()
except subprocess.CalledProcessError:
return b"NO_HEAD"


def _changed_paths() -> list[str]:
"""Paths git reports as changed/untracked, independent of staging state.

``--no-renames`` keeps one path per record (rename -> delete + add) so each
NUL-terminated record is ``XY<space><path>``. The path set is identical whether
a change is staged or not, which makes the signature stable across ``git add``.
"""
out = _git("status", "--porcelain=v1", "--no-renames", "-z")
paths: list[str] = []
for record in out.split(b"\x00"):
if len(record) <= _STATUS_PREFIX_LEN:
continue
paths.append(record[_STATUS_PREFIX_LEN:].decode("utf-8", "surrogateescape"))
return sorted(paths)


def _update_file(path: Path, digest: _Digest) -> None:
"""Fingerprint one regular file: full content when small, else size+mtime."""
try:
stat = path.stat()
except OSError:
digest.update(b"\x00UNREADABLE")
return
if stat.st_size <= _CONTENT_HASH_MAX_BYTES:
digest.update(b"\x00C")
try:
digest.update(path.read_bytes())
except OSError:
digest.update(b"UNREADABLE")
else:
digest.update(f"\x00S{stat.st_size}:{stat.st_mtime_ns}".encode())


def _update_for_path(rel: str, digest: _Digest) -> None:
digest.update(rel.encode("utf-8", "surrogateescape"))
path = Path(rel)
if not path.exists():
digest.update(b"\x00DELETED")
elif path.is_dir():
# git collapses a fully-untracked directory into one `dir/` entry. Such dirs
# are scratch (e.g. tmp/), not what the gate validates, so fingerprint their
# files by name+stat only — cheap, deterministic, and enough to notice drift.
for child in sorted(p for p in path.rglob("*") if p.is_file()):
digest.update(str(child).encode("utf-8", "surrogateescape"))
try:
cst = child.stat()
digest.update(f"\x00S{cst.st_size}:{cst.st_mtime_ns}".encode())
except OSError:
digest.update(b"\x00UNREADABLE")
else:
_update_file(path, digest)


def _signature() -> str:
digest = hashlib.sha256()
digest.update(_head())
digest.update(b"\x00")
for rel in _changed_paths():
_update_for_path(rel, digest)
return digest.hexdigest()


def _cmd_record() -> int:
_marker_path().write_text(_signature(), encoding="utf-8")
return 0


def _cmd_check() -> int:
marker = _marker_path()
if not marker.exists():
return 1
return 0 if marker.read_text(encoding="utf-8").strip() == _signature() else 1


def main(argv: list[str]) -> int:
if len(argv) != _ARGC_WITH_ACTION or argv[1] not in {"record", "check"}:
sys.stderr.write("usage: gate_marker.py {record|check}\n")
return 2
return _cmd_record() if argv[1] == "record" else _cmd_check()


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Loading