diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6c5e41b..be4de5ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,16 @@ jobs: - name: Install run: python -m pip install uv + # actionlint and gitleaks are Go binaries (no PyPI wheel), so check.sh self-skips + # them locally like shellcheck. Build them here with the runner's preinstalled Go, + # pinned to a release tag, and put GOPATH/bin on PATH so check.sh enforces them. + # (gitleaks v8's Go module path is still github.com/zricethezav/gitleaks/v8.) + - name: Workflow + secret scanners (actionlint, gitleaks) + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + go install github.com/zricethezav/gitleaks/v8@v8.21.2 + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + - name: Lint, typecheck, test run: ./scripts/check.sh diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..1df87308 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,18 @@ +# gitleaks config for the aai CLI secret-scan gate (scripts/check.sh + CI). +# +# Real credentials never live in this repo by design: the API key is stored only in +# the OS keyring (see aai_cli/config.py, KEYRING_SERVICE), never in a dotfile or source +# file. So the only "secrets" gitleaks can legitimately find are the obviously-fake +# placeholder keys used as test fixtures and in planning-doc examples. Allowlist those +# exact values — everything else (real-looking tokens in src/, scripts/, workflows) is +# still scanned and will fail the gate. +[extend] +useDefault = true + +[allowlist] +description = "Fake placeholder API keys used only in tests and planning docs" +regexTarget = "match" +regexes = [ + '''sk_abcdef1234''', + '''sk_zzzzzz9999''', +] diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index e372755f..a1c4fb60 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -1,5 +1,7 @@ from __future__ import annotations +from contextlib import suppress + import typer from rich.markup import escape @@ -106,13 +108,11 @@ def ask(transcript_text: str) -> str: with FollowRenderer(json_mode=json_mode) as render: transcript: list[str] = [] - try: + # Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0). + with suppress(KeyboardInterrupt): for turn in stdio.iter_piped_stdin_lines(): transcript.append(turn) render(ask("\n".join(transcript)), len(transcript)) - except KeyboardInterrupt: - # Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0). - pass def body(state: AppState, json_mode: bool) -> None: if not prompt: diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 4477bf72..fbb670d1 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -158,7 +158,7 @@ def stream_one( self, audio: Iterable[bytes], rate: int, *, source_label: str | None = None ) -> None: merged = config_builder.merge_streaming_params( - flags={**self.base_flags, "sample_rate": rate}, + flags=self.base_flags | {"sample_rate": rate}, overrides=self.overrides, config_file=self.config_file, ) @@ -453,7 +453,7 @@ def body(state: AppState, json_mode: bool) -> None: if opts.from_system_audio: raise UsageError("--show-code does not support macOS system audio capture yet.") merged = config_builder.merge_streaming_params( - flags={**base_flags, "sample_rate": TARGET_RATE}, + flags=base_flags | {"sample_rate": TARGET_RATE}, overrides=config_kv, config_file=config_file, ) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index b63a7c71..01ea6289 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -301,10 +301,8 @@ def body(state: AppState, json_mode: bool) -> None: max_tokens=max_tokens, ) output.emit( - { - **client.transcript_summary(transcript), - "transform": {"model": model, "steps": steps}, - }, + client.transcript_summary(transcript) + | {"transform": {"model": model, "steps": steps}}, _render_transform_steps, json_mode=json_mode, ) diff --git a/aai_cli/help_text.py b/aai_cli/help_text.py index 86503476..4ac5d4d6 100644 --- a/aai_cli/help_text.py +++ b/aai_cli/help_text.py @@ -19,6 +19,5 @@ def examples_epilog(examples: Sequence[Example]) -> str: """ blocks = ["[bold]Examples[/bold]"] for description, command in examples: - blocks.append(f"[dim]{escape(description)}[/dim]") - blocks.append(f"$ {escape(command)}") + blocks.extend((f"[dim]{escape(description)}[/dim]", f"$ {escape(command)}")) return "\n\n".join(blocks) diff --git a/aai_cli/streaming/sources.py b/aai_cli/streaming/sources.py index 820c592c..3d83fb8e 100644 --- a/aai_cli/streaming/sources.py +++ b/aai_cli/streaming/sources.py @@ -6,7 +6,7 @@ import sys import time import wave -from collections.abc import Callable, Iterator +from collections.abc import Callable, Generator, Iterator from pathlib import Path from typing import Any @@ -65,10 +65,19 @@ def __init__(self, source: str, *, sleep: Callable[[float], object] = time.sleep def __iter__(self) -> Iterator[bytes]: chunks = self._wav_chunks() if self._wav else self._ffmpeg_chunks() produced = 0 - for chunk in chunks: - produced += len(chunk) - yield chunk - self._sleep(len(chunk) / (TARGET_RATE * 2)) # ~real-time pacing + # Closing this outer generator early raises GeneratorExit at the `yield` below, + # but a plain `for` loop won't forward it into `chunks`; its cleanup (ffmpeg + # terminate/wait) would then run only at GC, not synchronously. Close it here so + # the subprocess teardown happens deterministically on early stop. + try: + for chunk in chunks: + produced += len(chunk) # pragma: no mutate (only == 0 matters) + yield chunk + # ~real-time pacing; tests inject a no-op sleep, so the duration is + # deliberately not asserted (it's cosmetic, not behavioral). + self._sleep(len(chunk) / (TARGET_RATE * 2)) # pragma: no mutate + finally: + chunks.close() if produced == 0: raise CLIError( f"No audio data in {self.source}.", @@ -77,7 +86,7 @@ def __iter__(self) -> Iterator[bytes]: suggestion="Check the file isn't empty or silent.", ) - def _wav_chunks(self) -> Iterator[bytes]: + def _wav_chunks(self) -> Generator[bytes, None, None]: frames_per_chunk = CHUNK_BYTES // 2 with wave.open(str(self._path), "rb") as w: # _wav implies a local path while True: @@ -86,7 +95,7 @@ def _wav_chunks(self) -> Iterator[bytes]: return yield data - def _ffmpeg_chunks(self) -> Iterator[bytes]: + def _ffmpeg_chunks(self) -> Generator[bytes, None, None]: proc = subprocess.Popen( [ "ffmpeg", diff --git a/pyproject.toml b/pyproject.toml index 41f49b64..86e5f57f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,8 @@ dev = [ "vulture>=2.14", "deptry>=0.23.0", "import-linter>=2.3", + "zizmor>=1.10", + "coverage>=7.0", ] [tool.uv] @@ -232,4 +234,6 @@ audioop-lts = "audioop" # The CLI templates carry their own requirements and are dependency-checked by # dedicated install tests, not by the root package metadata. DEP002 = ["fastapi", "python-dotenv", "python-multipart", "uvicorn"] -DEP004 = ["fastapi", "httpx", "hypothesis", "pytest"] +# coverage is read by scripts/mutation_gate.py (a dev-only gate run from check.sh, +# never shipped in the wheel), so deptry sees a dev dep imported from non-test code. +DEP004 = ["fastapi", "httpx", "hypothesis", "pytest", "coverage"] diff --git a/scripts/check.sh b/scripts/check.sh index 8c862d4f..129bc38d 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -115,6 +115,34 @@ else echo " shellcheck not found; skipping (CI runs it)" fi +echo "==> actionlint (GitHub Actions workflow lint)" +# Static-lint the CI workflows the same way shellcheck covers install.sh: catches +# bad expressions, undefined needs/matrix refs, and shell bugs inside `run:` blocks. +# Go binary (no PyPI wheel), so it self-skips locally and CI installs it (see ci.yml). +if command -v actionlint >/dev/null 2>&1; then + actionlint +else + echo " actionlint not found; skipping (CI runs it)" +fi + +echo "==> zizmor (GitHub Actions security audit)" +# Audits the workflows for CI security issues (script injection via untrusted +# ${{ github.* }} interpolation, over-broad token permissions, unpinned actions). +# Pip-installable, so it runs in the locked env as a hard gate like ruff/mypy. +# --offline keeps it deterministic (skips audits that would query the GitHub API). +uv run zizmor --offline .github/workflows + +echo "==> gitleaks (secret scan)" +# Defends the project's core promise that credentials never land in the repo (the API +# key lives only in the OS keyring). Scans the working tree; obviously-fake test/doc +# fixtures are allowlisted in .gitleaks.toml. Go binary, so it self-skips locally and +# CI installs it (see ci.yml). +if command -v gitleaks >/dev/null 2>&1; then + gitleaks dir --no-banner --redact -c .gitleaks.toml . +else + echo " gitleaks not found; skipping (CI runs it)" +fi + echo "==> generated --show-code compile gate" generated_code_dir="$(mktemp -d)" trap cleanup_generated_code_dir EXIT @@ -134,7 +162,7 @@ echo "==> pytest (with branch-coverage gate)" # uv run pytest -m e2e # uv run pytest -m install # uv run pytest -m install_script -uv run pytest -q --strict-config --strict-markers -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-report=xml --cov-fail-under=90 +uv run pytest -q --strict-config --strict-markers -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-context=test --cov-report=term-missing --cov-report=xml --cov-fail-under=90 echo "==> diff-cover (patch coverage: every changed line must be tested)" # The 90% gate above is project-wide, so new code can ride on the existing suite and @@ -148,6 +176,18 @@ else echo " origin/main not found; skipping patch-coverage gate (CI provides it)" fi +echo "==> mutation gate (diff-scoped: a changed line's test must fail when it breaks)" +# Coverage proves a changed line ran; this proves a test would FAIL if it broke. +# Mutates only the lines changed vs origin/main and reruns just the tests that cover +# each mutant (per-test contexts from the .coverage written above). Survivors mean a +# weak/missing assertion — fix it or mark the line `# pragma: no mutate`. Self-skips +# when origin/main is absent (same as diff-cover). +if git rev-parse --verify --quiet origin/main >/dev/null; then + uv run python scripts/mutation_gate.py origin/main +else + echo " origin/main not found; skipping mutation gate (CI provides it)" +fi + echo "==> no new static-analysis escape hatches" # Existing escape hatches are tolerated for now; new ones must be refactored away or # justified by changing this gate deliberately. Broad noqa/type-ignore/no-cover are @@ -162,6 +202,20 @@ if git rev-parse --verify --quiet origin/main >/dev/null; then exit 1 fi + # Test-suite escape hatches, same net-new-only policy: a skip/xfail is how an agent + # makes a red test go away instead of fixing it, and time.sleep() is the classic + # source of flakiness (use events/polling). The legitimate existing skips guard the + # env-gated marker suites (e2e/install/install_script) and live on origin/main, so + # they aren't added diff lines and don't trip this; a genuinely-needed new one must + # update this gate deliberately. Scoped to tests/ — production sleeps are fine. + test_shortcuts="$(git diff -U0 origin/main -- tests \ + | rg '^\+.*(pytest\.skip\(|pytest\.xfail\(|@pytest\.mark\.(skip|xfail)|\btime\.sleep\()' || true)" + if [[ -n "$test_shortcuts" ]]; then + printf '%s\n' "$test_shortcuts" + echo "New test skip/xfail/time.sleep found; fix the test (or sync properly) or update the gate explicitly." + exit 1 + fi + base_any_count="$({ git grep -n "Any" origin/main -- aai_cli tests || true; } | wc -l | tr -d '[:space:]')" work_any_count="$({ rg -n "Any" aai_cli tests || true; } | wc -l | tr -d '[:space:]')" if (( work_any_count > base_any_count )); then diff --git a/scripts/mutation_gate.py b/scripts/mutation_gate.py new file mode 100644 index 00000000..ef55fa19 --- /dev/null +++ b/scripts/mutation_gate.py @@ -0,0 +1,286 @@ +"""Diff-scoped mutation testing gate. + +Coverage proves a changed line *ran*; it can't prove a test would *fail* if that +line broke. This gate mutates only the lines changed versus the compare branch +(default origin/main), reruns just the tests that cover each mutant (read from +per-test coverage contexts in ``.coverage``), and fails if any mutant survives — +i.e. the suite still passed with the code deliberately broken. + +Run after the pytest step that wrote ``.coverage`` with ``--cov-context=test`` +(see scripts/check.sh). Mark a genuinely-equivalent or intentionally-unasserted +line ``# pragma: no mutate`` to exclude it. + +Usage: python scripts/mutation_gate.py [compare-branch] +""" + +from __future__ import annotations + +import ast +import re +import subprocess +import sys +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from pathlib import Path + +import coverage + +_PKG = "aai_cli" +_TEMPLATES = "aai_cli/init/templates" +_DEFAULT_MARKERS = "not e2e and not install and not install_script" +_TEST_TIMEOUT = 120 # seconds; a mutant that hangs (e.g. a flipped loop guard) counts killed +_SUPPRESS = "pragma: no mutate" + +_FILE_HEADER = re.compile(r"^\+\+\+ b/(?P.+)$") +_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P\d+)(?:,(?P\d+))? @@") + +# Each operator maps to its strongest single mutation (negation / boundary flip), +# which rarely produces an equivalent mutant. `not`-removal and statement deletion +# are deliberately omitted (they need parent rewiring and breed equivalents). +_COMPARE_SWAP: dict[type[ast.cmpop], type[ast.cmpop]] = { + ast.Lt: ast.GtE, + ast.GtE: ast.Lt, + ast.Gt: ast.LtE, + ast.LtE: ast.Gt, + ast.Eq: ast.NotEq, + ast.NotEq: ast.Eq, + ast.Is: ast.IsNot, + ast.IsNot: ast.Is, + ast.In: ast.NotIn, + ast.NotIn: ast.In, +} +_BOOL_SWAP: dict[type[ast.boolop], type[ast.boolop]] = {ast.And: ast.Or, ast.Or: ast.And} +_BINOP_SWAP: dict[type[ast.operator], type[ast.operator]] = { + ast.Add: ast.Sub, + ast.Sub: ast.Add, + ast.Mult: ast.Div, + ast.Div: ast.Mult, + ast.FloorDiv: ast.Mult, +} + + +@dataclass +class _Mutant: + label: str + linenos: frozenset[int] + apply: Callable[[], None] + undo: Callable[[], None] + + +def _git(*args: str) -> tuple[int, str]: + proc = subprocess.run(["git", *args], capture_output=True, text=True, check=False) + return proc.returncode, proc.stdout + + +def _merge_base(compare: str) -> str | None: + code, out = _git("merge-base", compare, "HEAD") + return out.strip() if code == 0 else None + + +def _changed_lines(base: str) -> dict[Path, set[int]]: + """Map each changed aai_cli/*.py file to the set of added line numbers.""" + _, out = _git("diff", "-U0", base, "--", _PKG) + result: dict[Path, set[int]] = {} + target: set[int] | None = None + for line in out.splitlines(): + header = _FILE_HEADER.match(line) + if header: + path = header.group("path") + keep = path.endswith(".py") and not path.startswith(_TEMPLATES) + target = result.setdefault(Path(path), set()) if keep else None + continue + hunk = _HUNK_HEADER.match(line) + if hunk and target is not None: + start = int(hunk.group("start")) + count = int(hunk.group("count") or "1") + target.update(range(start, start + count)) + return {path: lines for path, lines in result.items() if lines} + + +def _swap_in_list( + ops: list[ast.cmpop], index: int, old: ast.cmpop, new: type[ast.cmpop] +) -> tuple[str, Callable[[], None], Callable[[], None]]: + def apply() -> None: + ops[index] = new() + + def undo() -> None: + ops[index] = old + + return f"{type(old).__name__} -> {new.__name__}", apply, undo + + +def _swap_boolop( + node: ast.BoolOp, new: type[ast.boolop] +) -> tuple[str, Callable[[], None], Callable[[], None]]: + old = node.op + + def apply() -> None: + node.op = new() + + def undo() -> None: + node.op = old + + return f"{type(old).__name__} -> {new.__name__}", apply, undo + + +def _swap_arith( + node: ast.BinOp | ast.AugAssign, new: type[ast.operator] +) -> tuple[str, Callable[[], None], Callable[[], None]]: + old = node.op + + def apply() -> None: + node.op = new() + + def undo() -> None: + node.op = old + + return f"{type(old).__name__} -> {new.__name__}", apply, undo + + +def _set_const( + node: ast.Constant, new: object +) -> tuple[str, Callable[[], None], Callable[[], None]]: + old = node.value + + def apply() -> None: + node.value = new + + def undo() -> None: + node.value = old + + return f"{old!r} -> {new!r}", apply, undo + + +_Mutation = tuple[str, Callable[[], None], Callable[[], None]] + + +def _compare_mutations(node: ast.Compare) -> Iterator[_Mutation]: + for index, op in enumerate(node.ops): + new_cmp = _COMPARE_SWAP.get(type(op)) + if new_cmp is not None: + yield _swap_in_list(node.ops, index, op, new_cmp) + + +def _constant_mutations(node: ast.Constant) -> Iterator[_Mutation]: + if isinstance(node.value, bool): + yield _set_const(node, not node.value) + elif isinstance(node.value, int | float): + yield _set_const(node, node.value + 1) + + +def _node_mutations(node: ast.AST) -> Iterator[_Mutation]: + if isinstance(node, ast.Compare): + yield from _compare_mutations(node) + elif isinstance(node, ast.BoolOp): + new_bool = _BOOL_SWAP.get(type(node.op)) + if new_bool is not None: + yield _swap_boolop(node, new_bool) + elif isinstance(node, ast.BinOp | ast.AugAssign): + new_bin = _BINOP_SWAP.get(type(node.op)) + if new_bin is not None: + yield _swap_arith(node, new_bin) + elif isinstance(node, ast.Constant): + yield from _constant_mutations(node) + + +def _collect(path: Path, changed: set[int]) -> tuple[ast.Module, str, list[_Mutant]]: + src = path.read_text(encoding="utf-8") + lines = src.splitlines() + tree = ast.parse(src) + mutants: list[_Mutant] = [] + for node in ast.walk(tree): + lineno = getattr(node, "lineno", 0) + if lineno not in changed: + continue + end = getattr(node, "end_lineno", lineno) or lineno + span = frozenset(range(lineno, end + 1)) + # Scan the whole statement span, not just its first line: a `# pragma: no + # mutate` can land on any line the formatter wrapped the statement across. + if any(_SUPPRESS in lines[ln - 1] for ln in span): + continue + for desc, apply, undo in _node_mutations(node): + mutants.append(_Mutant(f"{path}:{lineno}: {desc}", span, apply, undo)) + return tree, src, mutants + + +def _covering_tests(data: coverage.CoverageData, path: Path, linenos: frozenset[int]) -> list[str]: + by_line = data.contexts_by_lineno(str(path.resolve())) + nodeids: set[str] = set() + for lineno in linenos: + for context in by_line.get(lineno, ()): + nodeid = context.split("|", 1)[0] + if nodeid: + nodeids.add(nodeid) + return sorted(nodeids) + + +def _run_tests(nodeids: list[str]) -> bool: + """True if the selected tests fail (the mutant is killed).""" + cmd = [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "no:randomly", + "--no-cov", + "-x", + "--no-header", + ] + cmd += nodeids if nodeids else ["-m", _DEFAULT_MARKERS, "tests"] + try: + proc = subprocess.run(cmd, capture_output=True, timeout=_TEST_TIMEOUT, check=False) + except subprocess.TimeoutExpired: + return True + return proc.returncode != 0 + + +def _survives( + path: Path, tree: ast.Module, src: str, mutant: _Mutant, data: coverage.CoverageData +) -> bool: + mutant.apply() + try: + path.write_text(ast.unparse(tree), encoding="utf-8") + killed = _run_tests(_covering_tests(data, path, mutant.linenos)) + finally: + path.write_text(src, encoding="utf-8") + mutant.undo() + return not killed + + +def _report(total: int, survivors: list[str]) -> int: + sys.stdout.write(f" tested {total} mutant(s) on changed lines\n") + if not survivors: + return 0 + sys.stdout.write("Surviving mutants (no test fails when this line is broken):\n") + for label in survivors: + sys.stdout.write(f" - {label}\n") + sys.stdout.write("Add an assertion that kills it, or mark the line `# pragma: no mutate`.\n") + return 1 + + +def main() -> int: + compare = sys.argv[1] if len(sys.argv) > 1 else "origin/main" + base = _merge_base(compare) + if base is None: + sys.stdout.write(f" {compare} not found; skipping mutation gate (CI provides it)\n") + return 0 + changed = _changed_lines(base) + if not changed: + sys.stdout.write(" no changed aai_cli lines to mutate\n") + return 0 + data = coverage.CoverageData() + data.read() + survivors: list[str] = [] + total = 0 + for path, lines in sorted(changed.items()): + tree, src, mutants = _collect(path, lines) + for mutant in mutants: + total += 1 + if _survives(path, tree, src, mutant, data): + survivors.append(mutant.label) + return _report(total, survivors) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 9e3b97a6..24dcf120 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "coverage" }, { name = "deptry" }, { name = "diff-cover" }, { name = "fastapi" }, @@ -49,6 +50,7 @@ dev = [ { name = "uvicorn" }, { name = "vulture" }, { name = "xenon" }, + { name = "zizmor" }, ] [package.metadata] @@ -71,6 +73,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "coverage", specifier = ">=7.0" }, { name = "deptry", specifier = ">=0.23.0" }, { name = "diff-cover", specifier = ">=9.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, @@ -89,6 +92,7 @@ dev = [ { name = "uvicorn", specifier = ">=0.30.0" }, { name = "vulture", specifier = ">=2.14" }, { name = "xenon", specifier = ">=0.9.3" }, + { name = "zizmor", specifier = ">=1.10" }, ] [[package]] @@ -1939,3 +1943,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d wheels = [ { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" }, ] + +[[package]] +name = "zizmor" +version = "1.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/41/8987d546e3101cc76748b2f1b0ccda58e244773ef5124d39e7e749e3d6e4/zizmor-1.25.2.tar.gz", hash = "sha256:f26ffeb16659c8922c7b08203ca5a4f8bf5e1a7e8d190734961c40877cf778ea", size = 517794, upload-time = "2026-05-16T06:28:43.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/bd/84108a92ccbfda0d28efc11f382997c7a767b58863bf4a550634b8cf0211/zizmor-1.25.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17cc8cfd9d472e8b11945a869c198d25cfdf4a33f36fa7a1f9674099f5fb509d", size = 9115548, upload-time = "2026-05-16T06:28:33.591Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c0/66453a2553a66286a96ca32d75e3e6bcc94ce7f907cd5f8c2c3fce55315e/zizmor-1.25.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3e301eb4465e2da77857cf01ab4ef0184cf3818e826800b270ab01ae7338977", size = 8665071, upload-time = "2026-05-16T06:28:30.861Z" }, + { url = "https://files.pythonhosted.org/packages/52/3e/d60939d1cc4907c0d021a7c46362aab5e8045550bb09157d56c070e43568/zizmor-1.25.2-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:cf64374149b567c9373228b76c8e77a389b4071899f84b82c36ee50fab894e79", size = 8842884, upload-time = "2026-05-16T06:28:26.041Z" }, + { url = "https://files.pythonhosted.org/packages/46/82/f3e8d9b6d941194f2558591b449c106d46a16ea566b95eccff3a83bf6acc/zizmor-1.25.2-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:0beba1601be08bd00c9277e6ed4b026e125b26b379d86d6d98eb708409b3050d", size = 8449741, upload-time = "2026-05-16T06:28:45.424Z" }, + { url = "https://files.pythonhosted.org/packages/4b/13/445bc98acc2c976d6b8f8ca59b9c09f055adb5ffb3445d99af8ff7efcb4f/zizmor-1.25.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:c4246f1344d8dbeffc044d7bb11b131773a7db7eb57d9073c45942dfd3543a1f", size = 9285184, upload-time = "2026-05-16T06:28:39.21Z" }, + { url = "https://files.pythonhosted.org/packages/cf/78/fc7717c706bde7531b2fde12003994fbc04c47ab4f91aa6ca9b3b24b30fd/zizmor-1.25.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dbb1b5c85b8de8eaa0227c6620f06c8e4fbd0a4da2086e218bc225c0bef0923d", size = 8886579, upload-time = "2026-05-16T06:28:51.384Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bc/a46f11377cdc145c625d62d88c30fead56f9d29bc31652069a1a0eaed6c2/zizmor-1.25.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d670a1e2f00b3cd56febd145bc1a0b2c4caf1cbe5dad8128721843fa877e2d2e", size = 8413576, upload-time = "2026-05-16T06:28:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3b/0fd93b77171c8f229e8e1304eecc9931bf3009f722c57967d545d9f151b6/zizmor-1.25.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b75c84d7387389f95edadbe859fb2aaf0a360c5b080932cc53e92ae1db6f09ef", size = 9378162, upload-time = "2026-05-16T06:28:41.999Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/dcb85fb9a0d87794847f9043f9db9bb4d274cf4b8077604bc13850c8fdb4/zizmor-1.25.2-py3-none-win32.whl", hash = "sha256:aa9f4c43b499c55339c3ef2e885133c5017cd9a18d76d9335541203cfa5ae1e7", size = 7548509, upload-time = "2026-05-16T06:28:28.828Z" }, + { url = "https://files.pythonhosted.org/packages/d2/81/1cb088098bd53f9b910098b0c19d06dc587acf328a170ef8afd1cd93b482/zizmor-1.25.2-py3-none-win_amd64.whl", hash = "sha256:af55bd9bd119ea8cbce2a7addc3922503019de32c1fe31106d70b3dc77d77908", size = 8609822, upload-time = "2026-05-16T06:28:48.078Z" }, +]