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
19 changes: 15 additions & 4 deletions .claude/rules/ci-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ Established by issue #1 scaffolding (DEC-003, DEC-009). Apply to every GitHub Ac

```yaml
- uses: actions/checkout@<40-char-sha> # v4.3.1
- uses: actions/setup-python@<40-char-sha> # v5.6.0
- uses: astral-sh/setup-uv@<40-char-sha> # v8.1.0
- uses: codecov/codecov-action@<40-char-sha> # v5.5.4
- uses: pypa/gh-action-pypi-publish@<40-char-sha> # v1.14.0
```

The trailing `# vX.Y.Z` comment is what reviewers and Dependabot read; the SHA is what GitHub Actions executes. Tags can be force-moved; SHAs cannot. Look up the SHA via:
Expand Down Expand Up @@ -36,9 +38,18 @@ concurrency:

Saves runner minutes and surfaces the latest result faster.

## Single Python version for early milestones
## Python matrix: 3.11 / 3.12 (uv migration)

DEC-003: lock to one Python version (currently `3.11`) for v0.1 CI. Widen the matrix when the package has real users running on multiple versions.
Originally DEC-003 locked CI to a single Python version (3.11) for v0.1 — the matrix widening was deferred to "when the package has real users running on multiple versions." The uv migration graduated this: `astral-sh/setup-uv` fetches missing interpreters in seconds, so a multi-version matrix costs ~2x runner minutes for a cleanly worth-it signal (PEP 604 / match-statement / type-param syntax issues catch earlier).

The matrix runs ruff + pytest on every Python version. Two steps are gated to one iteration:

- **Pyright** runs only when `matrix.python-version == '3.11'` — pyright's own `pythonVersion = "3.11"` setting pins the type-check to the floor (`python-build.md` issue #46); running it on 3.12 adds no signal.
- **Codecov upload** runs only when `matrix.python-version == '3.12'` (the current matrix ceiling) — coverage is interpreter-invariant for this codebase, so the choice is conventional. When 3.13 returns to the matrix, flip the gate back to 3.13.

**3.13 is deferred.** Python 3.13 changed `Path.resolve()` to raise `OSError(errno.ELOOP)` instead of `RuntimeError` on cyclic symlinks; six tests under `tests/_common/`, `tests/diff/`, `tests/grade/`, `tests/manifest/` rely on the 3.11/3.12 shape. A follow-up issue tracks the catch-site updates in `signalforge/_common/path_safety.py` + `signalforge/manifest/loader.py`; once those land, 3.13 returns to the matrix.

If a future ticket bumps the matrix floor (e.g., to 3.12), update `requires-python` + `pyright.pythonVersion` + the matrix in lockstep per `python-build.md` issue #46.

## CI triggers cover every long-lived branch

Expand All @@ -64,7 +75,7 @@ Three load-bearing details:

1. **SHA-pin the action** — same `gh api repos/codecov/codecov-action/git/refs/tags/v5` lookup as other actions. Dereference annotated tags. The trailing `# v5.X.Y` comment is for reviewers; the SHA is what executes.
2. **`fail_ci_if_error: false`** — required for fork-safe CI. Fork PRs via `pull_request` do not receive `secrets.CODECOV_TOKEN` (GitHub strips repository secrets from fork-originated workflows). The upload silently fails; `fail_ci_if_error: false` prevents CI failure on the missing token. This is expected behaviour, not a bug.
3. **No `if:` gate** — single-Python CI (DEC-003) means no matrix, so no `if: matrix.python-version == '3.13'`-style gate is needed. The upload step runs unconditionally after the pytest step.
3. **Gate on `matrix.python-version == '3.12'`** — the upload runs from the matrix ceiling iteration only, so coverage doesn't double-upload (codecov rejects duplicate uploads for the same commit). The choice of ceiling over floor is conventional; either matrix endpoint works. When 3.13 returns to the matrix (gated by issue #96), flip this to `'3.13'`.

The step must land AFTER the pytest step. `coverage.xml` is produced by `--cov-report=xml` in `pyproject.toml` `addopts` — no workflow-level `--cov` flag is needed.

Expand Down
28 changes: 16 additions & 12 deletions .claude/rules/python-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ include = ["src/signalforge/_demo"] # ship the bundled demo tree

Defence-in-depth: even when the current Hatchling behaviour appears to ship sibling data files under `packages`, the `include` directive is a contract surface that survives Hatchling version drift. Issue #47's empirical investigation found that current Hatchling versions sometimes do auto-include but the behaviour is not contractually documented — the `include` directive makes it explicit and gated by a CI test (see below).

**Maintainer-only `wheel_smoke` gate.** Issue #47 (DEC-003) lands a `@pytest.mark.wheel_smoke` test that shells out `python -m build --wheel` (or `uvx --from build pyproject-build` when `build` isn't in the venv), opens the artifact via `zipfile.ZipFile`, and asserts the canonical file set appears under the expected wheel path. Registration:
**Maintainer-only `wheel_smoke` gate.** Issue #47 (DEC-003) lands a `@pytest.mark.wheel_smoke` test that shells out `python -m build --wheel` (with `build` shipped via `[dependency-groups].dev`, the primary path always resolves under `uv run`; falls back to `uvx --from build pyproject-build` when `build` is unavailable), opens the artifact via `zipfile.ZipFile`, and asserts the canonical file set appears under the expected wheel path. Registration:

```toml
[tool.pytest.ini_options]
Expand All @@ -57,33 +57,37 @@ markers = [
addopts = "... -m 'not bigquery and not anthropic and not cli_subprocess and not e2e and not wheel_smoke'"
```

Maintainer runs `pytest -m wheel_smoke --no-cov` before declaring a packaging-touching PR ready. The `--no-cov` is required because `--cov-fail-under` in default `addopts` fails marker-specific runs that exercise only a fraction of the codebase (mirrors `pytest -m bigquery --no-cov` and `pytest -m cli_subprocess --no-cov` precedents from `testing-signal.md`).
Maintainer runs `uv run pytest -m wheel_smoke --no-cov` before declaring a packaging-touching PR ready. The `--no-cov` is required because `--cov-fail-under` in default `addopts` fails marker-specific runs that exercise only a fraction of the codebase (mirrors `uv run pytest -m bigquery --no-cov` and `uv run pytest -m cli_subprocess --no-cov` precedents from `testing-signal.md`).

**Belt-and-braces verification step.** Before merging any PR that touches `[tool.hatch.build.targets.wheel]`, run `python -m build --wheel && unzip -l dist/*.whl | grep <expected-path>` locally and inspect the file list. The wheel_smoke marker catches absence; the manual `unzip -l` catches surprising additions (e.g. cache directories, hidden build artefacts).
**Belt-and-braces verification step.** Before merging any PR that touches `[tool.hatch.build.targets.wheel]`, run `uv build && unzip -l dist/*.whl | grep <expected-path>` locally and inspect the file list. The wheel_smoke marker catches absence; the manual `unzip -l` catches surprising additions (e.g. cache directories, hidden build artefacts).

**Dotfile inclusion is fragile.** Hatchling's glob behaviour on dotfiles (`.gitignore`, `.env.example`) varies by version. The wheel_smoke test must explicitly assert dotfile presence alongside regular files — a regression that drops only the dotfile would otherwise slip through. Fallback if Hatchling silently strips a dotfile: ship as a non-dot name (e.g. `gitignore.demo`) and rewrite to the dot-name at copy time in the consuming code (issue #47 DEC-006 documents the fallback; not needed in v0.1 because current Hatchling preserves dotfiles under `include`).

## Editable install (zsh-safe)
## uv-managed dev environment (uv migration)

```bash
pip install -e ".[dev]"
```
The canonical dev install is `uv sync --dev`. `uv.lock` is committed; the lockfile carries the exact resolved versions every dev / CI run uses.

- `[dependency-groups].dev` in `pyproject.toml` is the uv-native (PEP 735) source of truth for dev deps. CI runs `uv sync --dev` then `uv run ruff check .` / `uv run pyright` / `uv run pytest`.
- `[project.optional-dependencies].dev` is kept in sync with `[dependency-groups].dev` for `pip install -e ".[dev]"` back-compat. The two lists deliberately mirror each other; `build` is the only extra entry that lives in `[dependency-groups].dev` only (powers the `wheel_smoke` marker under `uv run`).
- The setup-uv GitHub Action is SHA-pinned (`astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0`) per `ci-supply-chain.md`.

Quote the extras — `[dev]` is a glob in zsh and fails with `no matches found` unquoted.
The old "quote the `".[dev]"` — `[dev]` is a glob in zsh" gotcha drops away — `uv sync --dev` takes no glob-fragile argument.

## Python version: advertised floor matches the tested floor (issue #46)
## Python version: advertised floor matches the tested floor (issue #46, uv migration)

`pyproject.toml` declares `requires-python = ">=3.11"`; `[tool.pyright].pythonVersion` is `"3.11"`; `.github/workflows/ci.yml` runs on `python-version: "3.11"`. **All three agree** — what we advertise is what we type-check is what we test.
`pyproject.toml` declares `requires-python = ">=3.11"`; `[tool.pyright].pythonVersion` is `"3.11"`; `.github/workflows/ci.yml` runs a `python-version: ["3.11", "3.12"]` matrix. **All three agree on the floor** — what we advertise (`>=3.11`) is what we type-check (`3.11`) is what we test as the *floor of the matrix* (`3.11`). The 3.12 iteration runs pytest only; pyright is gated on `matrix.python-version == '3.11'` so the type-check pins to the advertised floor.

The original `>=3.10` floor was an aspirational support promise: the package could install on 3.10, but no CI job and no pyright pass exercised the 3.10 path. Three concrete divergence sources where 3.10-only code can pass review without being caught:

- `match`-statement exhaustiveness varies subtly between 3.10 and 3.11.
- PEP 604 union-type stringification semantics differ.
- PEP 695 type-parameter syntax (3.12+) is easy to slip in once PEP 604 is used.

Picked the cheaper of the two options from issue #46: narrow the floor to 3.11 rather than widen CI / pyright to a 3.10 matrix. v0.1 users who need 3.10 support can pin to a 3.10-compatible patch release; v0.2 will revisit if a real user reports.
Picked the cheaper of the two options from issue #46: narrow the floor to 3.11 rather than widen CI / pyright to a 3.10 matrix. v0.1 users who need 3.10 support can pin to a 3.10-compatible patch release.

The uv migration widened the CI matrix to 3.11 / 3.12 (uv's interpreter management makes a multi-version matrix essentially free). The intended ceiling is 3.13, but Python 3.13 changed `Path.resolve()` to raise `OSError(errno.ELOOP)` instead of `RuntimeError` on cyclic symlinks; six tests under the path-safety / sidecar / audit / manifest-loader surfaces rely on the 3.11/3.12 shape. A follow-up issue tracks updating the catch sites in `signalforge/_common/path_safety.py` and `signalforge/manifest/loader.py` so 3.13 can return to the matrix.

When CI widens to a Python matrix (likely v0.3, in lockstep with `ci-supply-chain.md` DEC-003 graduation), bump `pyright.pythonVersion` to the floor of the matrix AND keep `requires-python` aligned with that floor. The three values stay in lockstep; drift between them is exactly the bug this DEC closes.
The floor stays at 3.11; if a future ticket bumps the floor (e.g. for a stdlib feature only present in 3.12+), `requires-python` AND `pyright.pythonVersion` AND the matrix floor move in lockstep. Drift between them is exactly the bug this DEC closes.

## Reference

Expand Down
45 changes: 34 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,49 @@ concurrency:
jobs:
lint-test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
# 3.13 is deferred pending follow-up work on the path-safety
# layer — Python 3.13 changed `Path.resolve()` to raise
# `OSError(errno.ELOOP)` instead of `RuntimeError` on cyclic
# symlinks, and six tests under `tests/_common/`, `tests/diff/`,
# `tests/grade/`, `tests/manifest/` rely on the 3.11/3.12 shape.
# Re-add 3.13 once the catch sites in
# `signalforge/_common/path_safety.py` and
# `signalforge/manifest/loader.py` are updated.
python-version: ["3.11", "3.12"]
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
Comment thread
wjduenow marked this conversation as resolved.
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.11"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install
run: pip install -e ".[dev]"
# CI is read-only; never pushes. Don't persist the GITHUB_TOKEN
# in .git/config — least-privilege per actions/checkout security
# guidance (https://github.com/actions/checkout#:~:text=persist-credentials).
persist-credentials: false
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
python-version: ${{ matrix.python-version }}
- name: Sync dev dependencies
run: uv sync --dev
- name: Ruff check
run: ruff check .
run: uv run ruff check .
- name: Ruff format check
run: ruff format --check .
run: uv run ruff format --check .
- name: Pyright
run: pyright
# Pyright pins to the matrix FLOOR (`pyright.pythonVersion = "3.11"`
# in pyproject.toml). Running it 3x adds no signal — gate to once.
if: matrix.python-version == '3.11'
run: uv run pyright
- name: Pytest
run: pytest
run: uv run pytest
- name: Upload coverage to Codecov
# Upload from one matrix iteration (3.12, the current ceiling)
# to avoid duplicate uploads. Coverage is interpreter-invariant
# for this codebase; the choice is conventional, not load-bearing.
# When 3.13 returns to the matrix, flip this back to 3.13.
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
with:
files: coverage.xml
Expand Down
27 changes: 17 additions & 10 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
Comment thread
wjduenow marked this conversation as resolved.
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
# Build job doesn't push to git; least-privilege.
persist-credentials: false
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
# Pin the build interpreter to the supported floor for release
# reproducibility. Without this, `uv build` picks whatever
# `ubuntu-latest` happens to ship, which can drift over time.
python-version: "3.11"
- name: Install build
run: pip install build==1.2.*
- name: Build sdist + wheel
run: python -m build
run: uv build
Comment thread
wjduenow marked this conversation as resolved.
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
Expand All @@ -59,13 +63,16 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
# Build job doesn't push to git; least-privilege.
persist-credentials: false
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
# Pin the build interpreter to the supported floor for release
# reproducibility.
python-version: "3.11"
- name: Install build
run: pip install build==1.2.*
- name: Build sdist + wheel
run: python -m build
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,3 @@ work-*/
# Windows NTFS Alternate Data Streams (created when files cross from Windows -> WSL)
*:Zone.Identifier

# bark worktree scaffolding artifact
uv.lock
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,13 @@ Internals (`_loader_helpers`, `_sql_safety`, `_path_safety`, `_test_result_repr`

## Validation

Canonical validation command for this repo (run locally; CI runs the same four checks):
Canonical validation command for this repo (run locally; CI runs the same four checks across a 3.11 / 3.12 matrix — 3.13 is deferred pending the open path-safety follow-up):

```bash
pip install -e ".[dev]" && ruff check . && ruff format --check . && pyright && pytest
uv sync --dev && uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest
```

Quote the `".[dev]"` — bare `.[dev]` is a glob in zsh.
The repo is uv-managed (see `.claude/rules/python-build.md`). `pip install -e ".[dev]"` still works for contributors without uv (the `[project.optional-dependencies].dev` extra is kept in sync with `[dependency-groups].dev`), but uv is the default; `uv.lock` is committed.

## What SignalForge is

Expand Down
24 changes: 16 additions & 8 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@ SignalForge is pre-alpha and designing in the open. The differentiator is the pr

## Local development

The repo is uv-managed. Install [uv](https://docs.astral.sh/uv/), then:

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # quoted for zsh; bash also accepts this form
uv sync --dev
```

Validate before pushing:
uv reads `[dependency-groups].dev` in `pyproject.toml`, picks an interpreter
on the matrix floor (3.11) by default, and writes `uv.lock` (committed).
Contributors without uv can fall back to `pip install -e ".[dev]"` — the
`[project.optional-dependencies].dev` extra is kept in sync.

Validate before pushing (CI runs the same four checks on a 3.11 / 3.12
matrix; pyright is gated to the matrix floor, codecov upload to the ceiling.
3.13 is deferred — see the open Python-3.13 path-safety follow-up issue):

```bash
ruff check . && ruff format --check . && pyright && pytest
uv run ruff check . && uv run ruff format --check . && uv run pyright && uv run pytest
```

**Coverage:** see [`docs/codecov-ops.md`](docs/codecov-ops.md) for Codecov setup, badge interpretation, and threshold bumps.
Expand All @@ -35,15 +43,15 @@ total to catch regressions in the gated paths:

```bash
# 1. Default coverage (what the badge reports) — writes a fresh .coverage file:
pytest
uv run pytest

# 2. Append the gated-marker run to the SAME .coverage data file.
# --cov-append combines with run 1 so the term report shows the COMBINED total.
# --cov-fail-under=0 overrides the 80% gate inherited from addopts — gated
# markers alone never clear it, and this is a measurement, not a gate.
# (bigquery/anthropic/e2e need creds; cli_subprocess/wheel_smoke do not.)
SF_RUN_BQ=1 ANTHROPIC_API_KEY=sk-... GOOGLE_CLOUD_PROJECT=<billing-project> \
pytest -m 'bigquery or anthropic or e2e or cli_subprocess or wheel_smoke' \
uv run pytest -m 'bigquery or anthropic or e2e or cli_subprocess or wheel_smoke' \
--cov=signalforge --cov-append --cov-fail-under=0 --cov-report=term
```

Expand Down Expand Up @@ -88,7 +96,7 @@ Maintainers should run it once before declaring a CLI PR ready
(mirrors the `bigquery` integration-test gate):

```bash
pytest -m cli_subprocess --no-cov
uv run pytest -m cli_subprocess --no-cov
```

## BigQuery integration tests
Expand All @@ -108,7 +116,7 @@ SF_RUN_BQ)`.

2. Run with the gate:
```bash
SF_RUN_BQ=1 pytest -m bigquery --no-cov
SF_RUN_BQ=1 uv run pytest -m bigquery --no-cov
```

The tests query `bigquery-public-data.samples.shakespeare` (164K rows,
Expand Down
Loading
Loading