From 8a5f2e4fab81ec337a4f968a920866c111812c9e Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 16 Sep 2026 16:40:08 +0200 Subject: [PATCH 1/3] Pin the shared spelling baseline Fix the generated spelling policy to one reviewed dictionary revision. Verify the downloaded bytes before rendering so a fresh cache cannot silently accept a moving shared-base update. --- docs/developers-guide.md | 10 ++- scripts/generate_typos_config.py | 15 +++- scripts/tests/test_typos_rollout.py | 115 ++++++++++++++++++++++++++++ typos.toml | 2 + 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 7377e5295..f2325e7aa 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3055,10 +3055,12 @@ docstrings, string fixtures, and prose. Only spellings required by external contracts or deliberate spelling-test fixtures are exempt. Before checking the repository, the generator refreshes the shared/base -en-GB-oxendict dictionary into an untracked local cache only when the authority -is newer, then merges `typos.local.toml`. The generated `typos.toml` is -reviewed and committed so a clean, network-restricted checkout can still -enforce the last known-good policy. +en-GB-oxendict dictionary into an untracked local cache, then merges +`typos.local.toml`. `scripts/generate_typos_config.py` fixes the shared source +to its recorded commit and SHA-256, so a fresh checkout cannot silently adopt a +new `main` policy or render a different document as the selected baseline. The +generated `typos.toml` is reviewed and committed so a clean, network-restricted +checkout can still enforce the last known-good policy. Put an unavoidable external-contract spelling or a deliberate spelling-test fixture in `typos.local.toml` as a narrowly anchored `[patterns].ignore` entry. diff --git a/scripts/generate_typos_config.py b/scripts/generate_typos_config.py index efb680610..6ec2adb87 100644 --- a/scripts/generate_typos_config.py +++ b/scripts/generate_typos_config.py @@ -18,6 +18,7 @@ repository-specific policy that must not weaken the estate-wide base. """ +import hashlib import logging import tomllib import urllib.error @@ -28,13 +29,23 @@ DEFAULT_BASE_URL = ( "https://raw.githubusercontent.com/leynos/agent-helper-scripts/" - "refs/heads/main/data/typos-oxendict-base.toml" + "64bd9ce54942562cd89252b66fcedf5683324a78/data/typos-oxendict-base.toml" ) +PINNED_BASE_REVISION = "64bd9ce54942562cd89252b66fcedf5683324a78" +PINNED_BASE_SHA256 = "7eb3d405d49d466f918d189a671afc708fab26f982845ea82be3b1d377166b6a" REPOSITORY_ROOT = Path(__file__).resolve().parent.parent _logger = logging.getLogger(__name__) +def _verify_pinned_base(cache: Path) -> None: + """Reject a default-source cache whose bytes differ from the pinned base.""" + content_hash = hashlib.sha256(cache.read_bytes()).hexdigest() + if content_hash != PINNED_BASE_SHA256: + message = "pinned shared dictionary SHA-256 does not match the baseline" + raise ValueError(message) + + def dictionary_from_cache(repository: Path = REPOSITORY_ROOT) -> rollout.Dictionary: """Load the cached shared base merged with local repository policy. @@ -176,6 +187,8 @@ def main( if fallback is not None: return fallback raise + if source == DEFAULT_BASE_URL: + _verify_pinned_base(repository / ".typos-oxendict-base.toml") rollout.write_config(destination, dictionary_from_cache(repository)) return result diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index 85a7c7a4c..3f99ca2f0 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -7,6 +7,7 @@ import tomllib import typing as typ import urllib.error +import urllib.request import pytest @@ -57,6 +58,16 @@ class _InvalidDictionaryCase: match: str +@dc.dataclass(frozen=True, slots=True) +class _PinnedRefreshSetup: + """Capture the observable boundaries of one pinned shared-base refresh.""" + + generator: types.ModuleType + requests: list[urllib.request.Request] + timeouts: list[float] + verified_caches: list[Path] + + def test_rollout_generates_oxford_corrections( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], ) -> None: @@ -139,6 +150,110 @@ def unavailable(*_args: object, **_kwargs: object) -> None: ) +def test_default_source_pins_the_recorded_shared_baseline( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], +) -> None: + """The default source cannot silently resume following the shared main branch.""" + _, _, generator = rollout_modules + + expected_revision = "64bd9ce54942562cd89252b66fcedf5683324a78" + expected_hash = "7eb3d405d49d466f918d189a671afc708fab26f982845ea82be3b1d377166b6a" + expected_url = ( + "https://raw.githubusercontent.com/leynos/agent-helper-scripts/" + f"{expected_revision}/data/typos-oxendict-base.toml" + ) + + assert expected_revision == generator.PINNED_BASE_REVISION, ( + "the generator must retain this run's recorded shared revision" + ) + assert expected_hash == generator.PINNED_BASE_SHA256, ( + "the generator must retain this run's recorded shared content hash" + ) + assert expected_url == generator.DEFAULT_BASE_URL, ( + "the default shared dictionary URL must select the recorded commit" + ) + + +@pytest.fixture(name="pinned_refresh_setup") +def pinned_refresh_setup_fixture( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + patch_https_opener: cabc.Callable[[cabc.Callable[..., object]], None], + dictionary_text: cabc.Callable[..., str], +) -> _PinnedRefreshSetup: + """Set up a fresh pinned refresh and retain its observable boundaries.""" + _, _, generator = rollout_modules + setup = _PinnedRefreshSetup(generator, [], [], []) + + class Response: + """Return a small valid dictionary through the HTTPS test boundary.""" + + status = 200 + headers: typ.ClassVar[dict[str, str]] = {"ETag": '"pinned-base"'} + + def read(self, limit: int | None = None) -> bytes: + """Return the valid test dictionary, respecting the read limit.""" + return dictionary_text().encode()[:limit] + + def __enter__(self) -> Response: + """Enter the response context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the response context without suppressing failures.""" + + def open_response(request: urllib.request.Request, *, timeout: float) -> Response: + """Capture the immutable authority request and return valid content.""" + setup.requests.append(request) + setup.timeouts.append(timeout) + return Response() + + patch_https_opener(open_response) + monkeypatch.setattr( + generator, + "_verify_pinned_base", + setup.verified_caches.append, + ) + return setup + + +def test_fresh_default_refresh_requests_the_pinned_revision( + pinned_refresh_setup: _PinnedRefreshSetup, + tmp_path: Path, +) -> None: + """A fresh cache fetches the immutable default source before rendering.""" + result = pinned_refresh_setup.generator.main(repository=tmp_path) + + cache = tmp_path / ".typos-oxendict-base.toml" + assert result.status == "refreshed", ( + "a fresh default cache must report that it was populated" + ) + assert pinned_refresh_setup.timeouts == [30.0], ( + "the immutable shared dictionary request must use the 30-second timeout" + ) + assert [request.full_url for request in pinned_refresh_setup.requests] == [ + pinned_refresh_setup.generator.DEFAULT_BASE_URL + ], "the fresh cache request must use the immutable default URL" + assert pinned_refresh_setup.verified_caches == [cache], ( + "the fresh cache must be verified before generated output is accepted" + ) + assert cache.exists(), "a fresh default refresh must populate its cache" + + +def test_pinned_base_hash_mismatch_fails_closed( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, + dictionary_text: cabc.Callable[..., str], +) -> None: + """A valid but different shared document cannot pass for the pinned revision.""" + _, _, generator = rollout_modules + cache = tmp_path / ".typos-oxendict-base.toml" + cache.write_text(dictionary_text(), encoding="utf-8") + + with pytest.raises(ValueError, match="SHA-256 does not match"): + generator._verify_pinned_base(cache) + + @pytest.mark.parametrize( "case", [ diff --git a/typos.toml b/typos.toml index a865ecd0f..5796d1fe3 100644 --- a/typos.toml +++ b/typos.toml @@ -35,6 +35,8 @@ extend-ignore-re = [ "--artifact-name(?:[=\\s\"`]|$)", "--artifact-server-path(?:[=\\s\"`]|$)", "/artifacts(?:[/?#\"`]|$)", + "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b", + "GitHub\\s+Flavored\\s+Markdown\\b(?:\\s\\(GFM\\))?", "Upload main benchmark baseline artifact", "\\(\"ises\", \"izes\"\\)", "\\(\"teh\", \"(?:the|ten)\"\\)", From aa7ed9a726db9be2191ba13e812337a23a8e8a05 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 17 Sep 2026 12:22:05 +0200 Subject: [PATCH 2/3] Test pinned spelling digest acceptance Exercise the real successful digest check with the exact immutable shared-dictionary bytes. Keep the existing mismatch test fail-closed. --- .../data/typos-oxendict-base-64bd9ce.toml | 219 ++++++++++++++++++ scripts/tests/test_typos_rollout.py | 19 +- 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/data/typos-oxendict-base-64bd9ce.toml diff --git a/scripts/tests/data/typos-oxendict-base-64bd9ce.toml b/scripts/tests/data/typos-oxendict-base-64bd9ce.toml new file mode 100644 index 000000000..c42d7ff4e --- /dev/null +++ b/scripts/tests/data/typos-oxendict-base-64bd9ce.toml @@ -0,0 +1,219 @@ +# Shared en-GB-oxendict dictionary for the leynos code estate. +# +# Keep only generally valid Oxford forms and estate-wide terms here. Product +# names, quoted fixtures, and repository-specific identifiers belong in that +# repository's typos.local.toml overlay. + +schema = 1 + +[oxford] +stems = [ + "absolut", + "actual", + "alphabet", + "amort", + "anonym", + "apolog", + "atom", + "author", + "canonical", + "capital", + "categor", + "central", + "character", + "civil", + "colon", + "colour", + "common", + "conceptual", + "concret", + "container", + "contextual", + "critic", + "crystall", + "custom", + "daemon", + "decentral", + "denormal", + "derandom", + "deserial", + "desynchron", + "destabil", + "docker", + "dramat", + "emphas", + "energ", + "equal", + "external", + "factor", + "familiar", + "final", + "formal", + "fossil", + "general", + "global", + "harmon", + "homogen", + "human", + "hypothes", + "ideal", + "initial", + "industrial", + "institutional", + "internal", + "international", + "italic", + "item", + "jeopard", + "kernel", + "legitim", + "lemmat", + "linear", + "local", + "material", + "maxim", + "mechan", + "memo", + "memor", + "miniatur", + "minim", + "mobil", + "modern", + "modular", + "monet", + "monomorph", + "monopol", + "moral", + "neutral", + "normal", + "notar", + "operational", + "optim", + "ordinal", + "oxid", + "organ", + "palett", + "parallel", + "parameter", + "parametr", + "parenthes", + "penal", + "personal", + "plural", + "polymer", + "popular", + "priorit", + "privat", + "product", + "public", + "quant", + "random", + "raster", + "rational", + "real", + "recogn", + "regular", + "reinitial", + "reorgan", + "reserial", + "resynchron", + "retoken", + "romantic", + "sanit", + "scrutin", + "serial", + "sexual", + "singular", + "social", + "special", + "stabil", + "standard", + "styl", + "subsid", + "summar", + "symbol", + "synchron", + "synthes", + "system", + "systemat", + "templat", + "terminal", + "theor", + "token", + "trivial", + "unauthor", + "uncategor", + "underutil", + "uninitial", + "unlocal", + "unoptim", + "unparameter", + "unrecogn", + "unsynchron", + "util", + "vector", + "virtual", + "visual", + "weapon", + "zero", +] + +[words] +accepted = [ + "ASO", + "dialog", + "dialogs", + "handwritten", + "organizational", + "oxendict", + "yse", +] + +[words.corrections] +exercize = "exercise" +exercized = "exercised" +exercizes = "exercises" +exercizing = "exercising" +organisational = "organizational" +otherwize = "otherwise" +raize = "raise" +raized = "raised" +raizes = "raises" +raizing = "raising" + +[phrases.corrections] +"hand-written" = "handwritten" + +[patterns] +ignore = [ + '(?s)```.*?```', + '\brust-analyzer\b', + 'Azure\s+Architecture\s+Center\s*\|\s*Microsoft\s+Learn\b', + 'GitHub\s+Flavored\s+Markdown\b(?:\s\(GFM\))?', +] + +[files] +exclude = [ + ".git", + ".hypothesis", + ".pytest_cache", + ".tox", + ".terraform", + ".typos-oxendict-base.json", + ".typos-oxendict-base.toml", + ".uv-cache", + ".uv-tools", + ".venv", + "Cargo.lock", + "bun.lock", + "data/typos-oxendict-base.toml", + "dist", + "node_modules", + "package-lock.json", + "pnpm-lock.yaml", + "target", + "typos.local.toml", + "typos.toml", + "uv.lock", + "yarn.lock", +] diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index 3f99ca2f0..6ec49901e 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -3,18 +3,19 @@ from __future__ import annotations import dataclasses as dc +import hashlib import logging import tomllib import typing as typ import urllib.error import urllib.request +from pathlib import Path import pytest if typ.TYPE_CHECKING: import collections.abc as cabc import types - from pathlib import Path def _invalid_schema(dictionary_text: cabc.Callable[..., str]) -> str: @@ -254,6 +255,22 @@ def test_pinned_base_hash_mismatch_fails_closed( generator._verify_pinned_base(cache) +def test_pinned_base_hash_accepts_exact_pinned_bytes( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """The recorded dictionary bytes satisfy the immutable digest contract.""" + _, _, generator = rollout_modules + cache = tmp_path / ".typos-oxendict-base.toml" + fixture = Path(__file__).parent / "data" / "typos-oxendict-base-64bd9ce.toml" + pinned_bytes = fixture.read_bytes() + + assert hashlib.sha256(pinned_bytes).hexdigest() == generator.PINNED_BASE_SHA256 + + cache.write_bytes(pinned_bytes) + generator._verify_pinned_base(cache) + + @pytest.mark.parametrize( "case", [ From bc6000401ac6213be4c33fab158fe00a7b57d5a5 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 17 Sep 2026 12:48:36 +0200 Subject: [PATCH 3/3] Explain pinned digest assertion failure Show which immutable input violates the recorded digest when the success-path spelling fixture diverges from the selected baseline. --- scripts/tests/test_typos_rollout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index 6ec49901e..34ff07f3d 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -265,7 +265,9 @@ def test_pinned_base_hash_accepts_exact_pinned_bytes( fixture = Path(__file__).parent / "data" / "typos-oxendict-base-64bd9ce.toml" pinned_bytes = fixture.read_bytes() - assert hashlib.sha256(pinned_bytes).hexdigest() == generator.PINNED_BASE_SHA256 + assert hashlib.sha256(pinned_bytes).hexdigest() == generator.PINNED_BASE_SHA256, ( + "the immutable dictionary fixture must match the recorded SHA-256" + ) cache.write_bytes(pinned_bytes) generator._verify_pinned_base(cache)