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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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''',
]
8 changes: 4 additions & 4 deletions aai_cli/commands/llm.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from contextlib import suppress

import typer
from rich.markup import escape

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions aai_cli/commands/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
6 changes: 2 additions & 4 deletions aai_cli/commands/transcribe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
3 changes: 1 addition & 2 deletions aai_cli/help_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
23 changes: 16 additions & 7 deletions aai_cli/streaming/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}.",
Expand All @@ -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:
Expand All @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ dev = [
"vulture>=2.14",
"deptry>=0.23.0",
"import-linter>=2.3",
"zizmor>=1.10",
"coverage>=7.0",
]

[tool.uv]
Expand Down Expand Up @@ -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"]
56 changes: 55 additions & 1 deletion scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading