Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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