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

- name: Run template tests
run: make test

- name: Check spelling
run: make spelling
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
__pycache__/
.pytest_cache/

# Untracked cache of the estate-wide en-GB-oxendict dictionary.
.typos-oxendict-base.json
.typos-oxendict-base.toml
13 changes: 10 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
.PHONY: help check-fmt lint test typecheck
.PHONY: help check-fmt lint spelling test typecheck

MAKEFLAGS += --no-print-directory

UV := $(shell command -v uvx 2>/dev/null)
WITH_ACT ?= 0
ACT_TEST_ENV = $(if $(filter 1 true yes on,$(WITH_ACT)),RUN_ACT_VALIDATION=1,)
TYPOS_VERSION ?= 1.48.0
TYPOS = uv tool run typos@$(TYPOS_VERSION)
MD_FILES_FIND = find . -type f \( -name '*.md' -o -name '*.md.jinja' \) -not -path './.git/*' -print0

ifeq ($(strip $(UV)),)
$(error uvx is required to run template tests. Install uv from https://docs.astral.sh/uv/getting-started/installation/)
endif

test: ## Run template tests
$(ACT_TEST_ENV) $(UV) --with pytest-copier --with pyyaml --with syrupy --with make-parser pytest tests/
$(ACT_TEST_ENV) $(UV) --with hypothesis --with pytest-copier --with pyyaml --with syrupy --with make-parser pytest tests/

check-fmt: ## Verify template test formatting
$(UV) ruff format --check tests/
Expand All @@ -20,8 +23,12 @@ lint: ## Run template test lint checks
$(UV) ruff check tests/
$(UV) --with interrogate interrogate --fail-under 100 tests/

spelling: ## Enforce en-GB-oxendict spelling
uv run scripts/generate_typos_config.py
$(MD_FILES_FIND) | xargs -0 $(TYPOS) --config typos.toml --force-exclude

typecheck: ## Run template test type checks
$(UV) --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty check tests/
$(UV) --with hypothesis --with pytest --with pytest-copier --with pyyaml --with syrupy --with make-parser ty check tests/

help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
Expand Down
5 changes: 4 additions & 1 deletion docs/adr-001-template-quality-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ failures are predictable.
Generated projects use `make all` as the public aggregate gate. The `lint`
target delegates to language-specific targets:

- `lint-python` runs Ruff, Interrogate (`--fail-under 100`), and Pylint through the PyPy-backed runner.
- `lint-python` runs Ruff, Interrogate (`--fail-under 100`), and Pylint through
the PyPy-backed runner.
- `lint-rust` is rendered only when `use_rust` is enabled and runs rustdoc,
Clippy, and Whitaker.
- `spelling` generates shared en-GB-oxendict policy and runs the pinned
`typos` binary after the other aggregate prerequisites complete.

Tool revision pins are exposed as Makefile variables where the generated
Makefile owns installation or invocation. Generated Continuous Integration
Expand Down
37 changes: 37 additions & 0 deletions docs/adr-003-shared-oxford-spelling-base.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR-003: Generate spelling policy from a shared base

## Status

Accepted.

## Context

Generated projects need the same en-GB-oxendict policy as the wider `leynos`
estate. The `typos` `en-gb` locale enforces British `-our` and `-yse` forms but
prefers plain-British `-ise` over Oxford `-ize`. Copying curated overrides into
each generated project would cause drift, while making every exception global
would hide repository-specific mistakes.

## Decision

Generated projects refresh the tracked shared dictionary published by
`leynos/agent-helper-scripts` into ignored `.typos-oxendict-base.toml`, using
`.typos-oxendict-base.json` for source identity and freshness validators. The
generator merges that base with tracked `typos.local.toml` and writes a
deterministic, tracked `typos.toml`.

The refresh validates data before atomic replacement, keeps a valid cache when
its authority is not newer, and supports explicit offline reuse. The generated
Makefile pins `typos`, exposes `make spelling`, includes spelling in `make all`,
and runs it through Continuous Integration (CI). The template repository uses
the same mechanism for its own Markdown and rendered Markdown sources.

## Consequences

- Generic Oxford stems are curated once for the estate.
- Generated projects retain reviewable, reproducible configuration and work
offline after their cache is populated.
- Product names, upstream quotations, and deliberate fixtures remain narrow
local exceptions.
- A fresh checkout needs network access to collect the shared base before its
first spelling run.
31 changes: 23 additions & 8 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ template itself.
- `make lint` — runs Ruff lint checks and `interrogate --fail-under 100` against
the `tests/` directory in the parent template test suite.
- `make typecheck` — runs `ty check` against the parent template test suite,
supplying the test dependencies through `uvx`.
supplying the test dependencies, including Hypothesis, through `uvx`.
- `make test` — runs the template test suite via `uvx`, supplying
`pytest-copier`, `pyyaml`, `syrupy`, and `make-parser` without a manually
managed virtual environment.
Hypothesis, `pytest-copier`, `pyyaml`, `syrupy`, and `make-parser` without a
manually managed virtual environment.
- `make spelling` — refreshes the ignored estate-wide dictionary cache, merges
`typos.local.toml`, generates `typos.toml`, and checks Markdown plus rendered
Markdown template sources with the pinned `typos` version.
- `make test WITH_ACT=1` — sets `RUN_ACT_VALIDATION=1` inside the pytest
invocation, enabling the act-backed integration tests that run generated CI
workflows locally. Requires `act` and Docker to be available.
Expand All @@ -31,9 +34,9 @@ template itself.
The parent repository uses two separate GitHub Actions workflows to keep
Docker-dependent tests isolated from the standard template test gate:

- `.github/workflows/ci.yml` runs `make test` on every push to `main` and on
all pull requests. It installs `markdownlint-cli2` and `mbake` at pinned
versions.
- `.github/workflows/ci.yml` runs `make test` and `make spelling` on every push
to `main` and on all pull requests. It installs `markdownlint-cli2` and
`mbake` at pinned versions.
- `.github/workflows/act-validation.yml` runs `make test WITH_ACT=1`. It
additionally downloads the `act` binary at a pinned `ACT_VERSION`, verifies
its SHA-256 checksum before extraction, and confirms Docker availability via
Expand All @@ -44,7 +47,9 @@ Docker-dependent tests isolated from the standard template test gate:
## Makefile Template

`template/Makefile.jinja` defines the generated developer workflow. The default
`all` target runs build, formatting, linting, typechecking, and tests.
`all` target runs build, formatting, linting, typechecking, tests, and spelling.
The spelling recipe runs last so generated configuration cannot race tests when
callers enable parallel Make execution.
The generated `audit` target is an explicit security gate run by CI. It runs
`pip-audit` for every rendered project and, when `use_rust` is enabled, also
runs `cargo audit` in the Rust extension crate.
Expand All @@ -68,7 +73,7 @@ override pins without editing target recipes.

`template/.github/workflows/ci.yml.jinja` mirrors the generated local gates. It
sets up Python, optionally sets up Rust, runs `make check-fmt`, `make lint`,
`make typecheck`, and `make audit`, then delegates coverage to
`make typecheck`, `make spelling`, and `make audit`, then delegates coverage to
`leynos/shared-actions/.github/actions/generate-coverage`.

The shared coverage action runs Python coverage through xdist-backed SlipCover
Expand All @@ -83,6 +88,16 @@ Docker availability, and runs `make test WITH_ACT=1`.
Rust-enabled workflows pass `rust_extension/Cargo.toml` to the coverage action
because the generated Python project root does not contain a Rust manifest.

## Shared Spelling Configuration

[ADR-003](adr-003-shared-oxford-spelling-base.md) records the shared-base
decision. Both the parent and generated project keep
`.typos-oxendict-base.toml` and `.typos-oxendict-base.json` untracked. Generic
Oxford stems belong in `leynos/agent-helper-scripts`; repository-only accepted
words, patterns, and file exclusions belong in `typos.local.toml`. Regenerate
tracked configuration with `uv run scripts/generate_typos_config.py` rather
than editing `typos.toml`.

## Rust Integration

When `use_rust` is enabled, the template renders a PyO3 extension under
Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[pytest]
markers =
act: local GitHub Actions workflow validation through act
slow: exercises an external tool or process boundary
63 changes: 63 additions & 0 deletions scripts/generate_typos_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env -S uv run python
# /// script
# requires-python = ">=3.13"
# dependencies = []
# ///
"""Generate ``typos.toml`` from the shared en-GB-oxendict dictionary.

The shared dictionary is refreshed into an untracked repository-local cache
only when the authoritative copy is newer. A valid cache remains usable when
the network is unavailable, and ``typos.local.toml`` supplies the narrow
repository-specific policy that must not weaken the estate-wide base.
"""

from pathlib import Path

import typos_rollout as rollout

DEFAULT_BASE_URL = (
"https://raw.githubusercontent.com/leynos/agent-helper-scripts/"
"refs/heads/main/data/typos-oxendict-base.toml"
)
REPOSITORY_ROOT = Path(__file__).resolve().parent.parent


def dictionary_from_cache(repository: Path = REPOSITORY_ROOT) -> rollout.Dictionary:
"""Load the cached shared base merged with local repository policy."""
dictionary = rollout.load_dictionary(repository / ".typos-oxendict-base.toml")
local_overlay = repository / "typos.local.toml"
if local_overlay.exists():
dictionary = rollout.merge_dictionaries(
dictionary,
rollout.load_dictionary(local_overlay),
)
return dictionary


def render_config(repository: Path = REPOSITORY_ROOT) -> str:
"""Render deterministic configuration from the populated local cache."""
return rollout.render_typos_config(dictionary_from_cache(repository))


def main(
output: Path | None = None,
*,
repository: Path = REPOSITORY_ROOT,
source: str | Path = DEFAULT_BASE_URL,
offline: bool = False,
) -> rollout.RefreshResult:
"""Refresh the shared base cache and write the merged configuration."""
result = rollout.refresh_base(
source,
repository / ".typos-oxendict-base.toml",
metadata=repository / ".typos-oxendict-base.json",
offline=offline,
)
destination = output if output is not None else repository / "typos.toml"
rollout.write_config(destination, dictionary_from_cache(repository))
return result


if __name__ == "__main__":
refresh = main()
print(f"{refresh.status}: {REPOSITORY_ROOT / 'typos.toml'}")
Loading
Loading