From 028c31fbc04cbef79f3b3d20c201957aa60b3bd5 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 13 Jul 2026 23:38:13 +0200 Subject: [PATCH 1/5] Enforce Oxford spelling with typos Generate the repository policy from the shared estate dictionary and a narrow local overlay. Refresh the untracked base only when the authority is newer, validate the helper and generated config, and run the pinned spelling gate in CI. Correct the maintained documentation spellings now enforced by the gate. --- .github/workflows/ci.yml | 4 +- .gitignore | 4 + AGENTS.md | 12 +- Makefile | 31 +- docs/developers-guide.md | 53 +- docs/netsuke-design.md | 6 +- ...esting-in-rust-via-dependency-injection.md | 2 +- scripts/generate_typos_config.py | 299 +- scripts/tests/test_generate_typos_config.py | 166 - scripts/tests/test_typos_rollout.py | 395 ++ scripts/tests/test_typos_rollout_hardening.py | 351 ++ scripts/typos_rollout.py | 237 + scripts/typos_rollout_cache.py | 53 + scripts/typos_rollout_http.py | 340 ++ typos.local.toml | 18 + typos.toml | 4766 ++++++++++------- 16 files changed, 4501 insertions(+), 2236 deletions(-) delete mode 100644 scripts/tests/test_generate_typos_config.py create mode 100644 scripts/tests/test_typos_rollout.py create mode 100644 scripts/tests/test_typos_rollout_hardening.py create mode 100644 scripts/typos_rollout.py create mode 100644 scripts/typos_rollout_cache.py create mode 100644 scripts/typos_rollout_http.py create mode 100644 typos.local.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c9f8f6f6..0acf8f4cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,10 +80,10 @@ jobs: with: python-version: '3.13' enable-cache: true + - name: Spelling + run: make spelling - name: Workflow contract tests run: make test-workflow-contracts - - name: Typos config drift test - run: make test-typos-config - name: Test run: make test - name: Test and Measure Coverage diff --git a/.gitignore b/.gitignore index 30763cf3c..354058f41 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ build.ninja /graph.dot /graph.html __pycache__/ +.uv-cache/ +.uv-tools/ +.typos-oxendict-base.json +.typos-oxendict-base.toml diff --git a/AGENTS.md b/AGENTS.md index 67174dbde..8c8d022b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,12 +316,12 @@ project: en-GB-oxendict (Oxford) spelling over Markdown prose with [`typos`](https://github.com/crate-ci/typos), pinned by the Makefile `TYPOS_VERSION` variable and run through `uv tool run`. -- `typos.toml` is generated; never edit its entries by hand. Change - `scripts/generate_typos_config.py` and regenerate with - `uv run scripts/generate_typos_config.py`. When the gate flags a legitimate - Oxford `-ize` word, add its stem to the generator's `STEMS`; for identifiers - or API names that must keep upstream spelling, prefer backticks (which the - gate ignores) over widening the accepted-word list. See the +- `typos.toml` is generated from the shared estate dictionary and the narrow + repository policy in `typos.local.toml`; never edit generated entries by + hand. Change the local overlay and regenerate with + `uv run scripts/generate_typos_config.py`. For identifiers or API names that + must keep upstream spelling, prefer backticks (which the gate ignores) over + widening the accepted-word list. See the [developers' guide](docs/developers-guide.md) for the full workflow. - Run `make fmt` after any documentation changes to format all Markdown files and fix table markup. diff --git a/Makefile b/Makefile index cceab8440..6b147476d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test test-workflow-contracts test-typos-config build release lint lint-clippy lint-whitaker fmt check-fmt typecheck markdownlint nixie install-kani kani-check kani-full kani-ir install-verus verus formal-pr +.PHONY: help all clean test test-workflow-contracts test-typos-config build release lint lint-clippy lint-whitaker fmt check-fmt typecheck markdownlint spelling spelling-helper-test nixie install-kani kani-check kani-full kani-ir install-verus verus formal-pr APP ?= netsuke CARGO ?= $(shell command -v cargo 2>/dev/null || printf '%s' "$$HOME/.cargo/bin/cargo") @@ -14,7 +14,15 @@ NIXIE ?= nixie # Single source of truth for the typos version; the markdownlint target and CI # both consume it, so the Makefile and CI cannot drift apart. TYPOS_VERSION ?= 1.48.0 -TYPOS ?= uv tool run typos@$(TYPOS_VERSION) +UV ?= uv +UV_ENV = UV_CACHE_DIR=.uv-cache UV_TOOL_DIR=.uv-tools +RUFF_VERSION ?= 0.15.12 +SPELLING_HELPER_COVERAGE = --cov=generate_typos_config --cov=typos_rollout \ + --cov=typos_rollout_cache --cov=typos_rollout_http +SPELLING_HELPER_FILES = scripts/generate_typos_config.py \ + scripts/typos_rollout.py scripts/typos_rollout_cache.py \ + scripts/typos_rollout_http.py scripts/tests/test_typos_rollout.py \ + scripts/tests/test_typos_rollout_hardening.py # Markdown files, excluding build output and tool caches. CRUSH.md is a symlink # to AGENTS.md, so `-type f` skips it and avoids double-checking the same prose. MD_FILES_FIND = find . -type f -name '*.md' \ @@ -44,8 +52,7 @@ test: ## Run tests with warnings treated as errors test-workflow-contracts: ## Validate the mutation-testing caller contract uv run --with 'pytest>=8' --with 'pyyaml>=6' pytest tests/workflow_contracts -q -test-typos-config: ## Verify typos.toml matches its generator - uv run --with 'pytest>=8' --with 'hypothesis>=6' pytest scripts/tests -q +test-typos-config: spelling-helper-test ## Verify the shared spelling-policy integration target/%/$(APP): ## Build binary in debug or release mode $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(APP) @@ -69,10 +76,20 @@ check-fmt: ## Verify formatting typecheck: ## Typecheck all targets and features RUSTFLAGS="-D warnings" $(CARGO) check --all-targets --all-features $(BUILD_JOBS) -markdownlint: ## Lint Markdown and enforce en-GB-oxendict spelling +markdownlint: spelling ## Lint Markdown and enforce en-GB-oxendict spelling $(MDLINT) "**/*.md" - @printf 'typos: version=%s config=typos.toml\n' '$(TYPOS_VERSION)' >&2 - $(MD_FILES_FIND) | xargs -0 $(TYPOS) --config typos.toml --force-exclude + +spelling: spelling-helper-test ## Enforce en-GB-oxendict spelling in Markdown prose + @$(UV_ENV) $(UV) run scripts/generate_typos_config.py + @git ls-files --error-unmatch typos.toml >/dev/null + @git diff --exit-code -- typos.toml + @$(MD_FILES_FIND) | xargs -0 -r env $(UV_ENV) \ + $(UV) tool run typos@$(TYPOS_VERSION) --config typos.toml --force-exclude + +spelling-helper-test: ## Validate the shared spelling-policy integration + @$(UV_ENV) $(UV) tool run ruff@$(RUFF_VERSION) format --isolated --target-version py313 --check $(SPELLING_HELPER_FILES) + @$(UV_ENV) $(UV) tool run ruff@$(RUFF_VERSION) check --isolated --target-version py313 $(SPELLING_HELPER_FILES) + @PYTHONPATH=scripts $(UV_ENV) $(UV) run --no-project --python 3.13 --with pytest==9.0.2 --with pytest-cov==7.0.0 python -m pytest scripts/tests/test_typos_rollout*.py -c /dev/null --rootdir=. -p no:cacheprovider $(SPELLING_HELPER_COVERAGE) --cov-fail-under=90 nixie: ## Validate Mermaid diagrams nixie --no-sandbox diff --git a/docs/developers-guide.md b/docs/developers-guide.md index fd2d6f3a3..7d62dddf1 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -114,37 +114,37 @@ For documentation changes, also run: `make markdownlint` enforces en-GB-oxendict (Oxford) spelling over the repository's Markdown prose with [`typos`](https://github.com/crate-ci/typos), as required by the [documentation style guide](documentation-style-guide.md). -The configuration lives in the repository-root `typos.toml` and works in two -layers: +The repository-root `typos.toml` is deterministic generated output assembled +from two policy layers: -1. The `en-gb` locale corrects American spellings (`color` to `colour`, - `behavior` to `behaviour`, `analyzed` to `analysed`). -2. Generated `extend-words` entries restore Oxford spelling, which the locale - alone would not enforce: identity entries accept `-ize` inflections that the - locale would otherwise "correct" to `-ise`, and `-ise` entries are corrected - to `-ize`. Stems taking `-yse` (`analyse`, `paralyse`) are left to the - locale, which already enforces them. +1. The shared estate dictionary in `leynos/agent-helper-scripts` supplies + generally valid Oxford forms, accepted technical terms, corrections and + exclusions. The generator conditionally refreshes this authority into an + untracked local cache and reuses a valid cache when working offline. +2. `typos.local.toml` contains only Netsuke-specific names, identifiers, + fixtures and exclusions. It cannot replace a conflicting shared correction. -`typos.toml` is a generated file. Never edit its entries by hand; change -`scripts/generate_typos_config.py` and regenerate: +`scripts/typos_rollout_http.py` owns shared-cache freshness, HTTPS transport +security and persistence coordination. Only `scripts/typos_rollout.py` may +compose it with dictionary validation; application and release code must not +reuse these spelling-policy internals. + +The generated policy sets the `en-gb` locale to correct American spellings +(`color` to `colour`, `behavior` to `behaviour`, `analyzed` to `analysed`). It +also restores Oxford spelling through generated entries that accept `-ize` +inflections and correct their plain-British `-ise` equivalents. Stems taking +`-yse` (`analyse`, `paralyse`) remain governed by the locale. + +Never edit `typos.toml` by hand. Change `typos.local.toml` and regenerate: ```bash uv run scripts/generate_typos_config.py ``` -The generator owns three maintainer-facing lists: - -- `STEMS` — word stems that take Oxford `-ize`. When the gate flags a - legitimate `-ize` word (or silently accepts its `-ise` variant) because the - stem is missing, add the stem here and regenerate. Do not add genuinely - `-ise`-only words (`advise`, `revise`, `exercise`, `supervise`). -- `EXTRA_ACCEPTED_WORDS` — words accepted verbatim, such as suffix fragments - quoted in prose (`yse`, `mis`) and non-English example text (`inventario`). -- `extend-ignore-re` patterns in `HEADER` — regions exempt from spelling - checks: inline code spans, fenced code blocks, tool and target names that - keep their upstream spelling (`rust-analyzer`, `release-artifact`), and the - ExecPlan template heading. Quoted APIs keep US spelling per the documentation - style guide, so put them in backticks rather than adding accepted words. +If a legitimate Oxford form is missing estate-wide, update the shared +dictionary rather than duplicating it locally. Keep proper names and deliberate +fixtures in `typos.local.toml`. Quoted APIs keep upstream spelling, so put them +in backticks rather than adding accepted words. `make markdownlint` runs the gate with `--force-exclude`, so the `typos.toml` excludes also apply to explicitly passed paths. To fix findings mechanically, @@ -161,8 +161,9 @@ touch code samples, API names, or quoted material. The `typos` version is pinned once in the Makefile `TYPOS_VERSION` variable and run through `uv tool run typos@$(TYPOS_VERSION)`, so the local gate and CI -cannot drift. `make test-typos-config` (also run in CI) asserts that the -committed `typos.toml` matches the generator output, so the two never diverge. +cannot drift. `make spelling` validates the helper implementation, regenerates +the policy, rejects tracked drift, and scans every tracked Markdown file. +`make test-typos-config` remains an alias for the focused helper tests. ## Release help tooling diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index c41988ead..240b69d71 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1550,8 +1550,8 @@ Implementation details: via capability-restricted handles from `StdlibConfig`. Templates can no longer override the cache path, ensuring caches remain bounded by the workspace. Remote fetches and cache writes mark the stdlib state as impure so - callers can discard memoised renders, while cache hits remain pure and - preserve memoised renders. + callers can discard memoized renders, while cache hits remain pure and + preserve memoized renders. - `fetch` enforces a configurable response limit (default 8 MiB) and streams cached downloads directly to disk. Exceeding the budget aborts the request with an error that quotes the configured byte cap so template authors can @@ -2973,7 +2973,7 @@ projects. object files and linking them into a small application. - [`photo_edit.yml`](../examples/photo_edit.yml): converts RAW photographs and generates a simple HTML gallery for previewing the results. -- [`visual_design.yml`](../examples/visual_design.yml): rasterises a set of SVG +- [`visual_design.yml`](../examples/visual_design.yml): rasterizes a set of SVG design assets into PNG images using Inkscape. - [`website.yml`](../examples/website.yml): builds a static web site from Markdown pages with Pandoc and assembles an index page. diff --git a/docs/reliable-testing-in-rust-via-dependency-injection.md b/docs/reliable-testing-in-rust-via-dependency-injection.md index f26d87181..94173e722 100644 --- a/docs/reliable-testing-in-rust-via-dependency-injection.md +++ b/docs/reliable-testing-in-rust-via-dependency-injection.md @@ -1,6 +1,6 @@ # 🛡️ Reliable Testing in Rust via Dependency Injection -Writing robust, reliable, and parallelisable tests requires an intentional +Writing robust, reliable, and parallelizable tests requires an intentional approach to handling external dependencies such as environment variables, the filesystem, or the system clock. Functions that directly call `std::env::var` or `SystemTime::now()` are difficult to test because they depend on global, diff --git a/scripts/generate_typos_config.py b/scripts/generate_typos_config.py index f47c5bf9b..8800df82f 100644 --- a/scripts/generate_typos_config.py +++ b/scripts/generate_typos_config.py @@ -3,248 +3,83 @@ # requires-python = ">=3.13" # dependencies = [] # /// -"""Generate ``typos.toml`` enforcing en-GB-oxendict (Oxford) spelling. +"""Generate ``typos.toml`` from the shared en-GB-oxendict dictionary. -The ``en-gb`` locale in ``typos`` corrects American spellings to British -ones, but it prefers the ``-ise`` family and rejects Oxford ``-ize`` forms. -For each curated stem this script therefore emits: - -- an identity entry accepting each Oxford ``-ize`` inflection that the - locale would otherwise "correct" to ``-ise``, and -- an ``-ise`` to ``-ize`` correction for the same inflection, which the - locale accepts silently. - -Stems taking ``-yse`` (analyse, paralyse, catalyse) are deliberately -absent: Oxford retains ``-yse`` and the locale already enforces it. -Genuinely ``-ise``-only words (advise, revise, exercise, supervise, and so -on) are excluded from the stem list, so they remain accepted. - -Examples --------- -Run from the repository root to rewrite ``typos.toml`` in place: - ->>> # uv run scripts/generate_typos_config.py # doctest: +SKIP - -The stem ``organ`` produces entries such as ``organised = "organized"`` -and ``organized = "organized"`` for every suffix pair. +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. """ +import tomllib +import urllib.parse from pathlib import Path -#: Word stems that take Oxford ``-ize``; each stem is the word up to but -#: not including the ``-ise``/``-ize`` suffix. Curated for vocabulary -#: plausible in software documentation; extend as new words appear. -STEMS = ( - "anonym", - "apolog", - "author", - "canonical", - "capital", - "categor", - "central", - "character", - "civil", - "colon", - "conceptual", - "container", - "contextual", - "critic", - "custom", - "decentral", - "denormal", - "deserial", - "destabil", - "emphas", - "energ", - "equal", - "external", - "factor", - "familiar", - "final", - "formal", - "fossil", - "general", - "global", - "harmon", - "hypothes", - "ideal", - "initial", - "internal", - "international", - "item", - "jeopard", - "legitim", - "linear", - "local", - "material", - "maxim", - "mechan", - "memor", - "miniatur", - "minim", - "mobil", - "modern", - "modular", - "monet", - "neutral", - "normal", - "operational", - "optim", - "organ", - "parameter", - "parametr", - "penal", - "personal", - "plural", - "priorit", - "public", - "quant", - "random", - "rational", - "real", - "recogn", - "regular", - "reinitial", - "reorgan", - "sanit", - "scrutin", - "serial", - "singular", - "special", - "stabil", - "standard", - "styl", - "subsid", - "summar", - "symbol", - "synchron", - "synthes", - "systemat", - "theor", - "token", - "trivial", - "unauthor", - "uncategor", - "uninitial", - "unoptim", - "unrecogn", - "unsynchron", - "util", - "vector", - "virtual", - "visual", -) - -#: Words accepted verbatim: suffix fragments quoted in prose, hyphenated -#: prefixes split by the tokenizer, and non-English example text. -EXTRA_ACCEPTED_WORDS = ( - "yse", # the "-yse" suffix quoted in spelling guidance - "mis", # hyphenated prefix, as in "mis-applied" - "inventario", # Spanish Gherkin example in the rstest-bdd users' guide -) +import typos_rollout as rollout -#: (British ``-ise`` inflection, Oxford ``-ize`` inflection) suffix pairs. -SUFFIX_PAIRS = ( - ("ise", "ize"), - ("ises", "izes"), - ("ised", "ized"), - ("ising", "izing"), - ("iser", "izer"), - ("isers", "izers"), - ("isable", "izable"), - ("isation", "ization"), - ("isations", "izations"), +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 -HEADER = """\ -# typos configuration enforcing en-GB-oxendict (Oxford spelling). -# -# Generated by scripts/generate_typos_config.py; regenerate rather than -# editing entries by hand: -# -# uv run scripts/generate_typos_config.py -# -# The en-gb locale corrects American spellings (color -> colour, -# behavior -> behaviour, analyzed -> analysed) but prefers -ise over the -# Oxford -ize. The extend-words table below restores Oxford spelling: -# identity entries accept -ize inflections, and -ise entries are -# corrected to -ize. - -[files] -extend-exclude = [ - ".git", - ".venv", - ".uv-cache", - ".uv-tools", - "target", - "node_modules", - "Cargo.lock", - "CRUSH.md", -] - -[default] -locale = "en-gb" -extend-ignore-re = [ - # Inline code spans and fenced code blocks quote APIs and identifiers, - # which keep their upstream spelling (for example `color`, `flavor`). - '`[^`\\n]+`', - '(?s)```.*?```', - # Tool and Makefile target names that keep their upstream spelling - # even when they appear outside code spans. - 'rust-analyzer', - 'release-artifact', - # ExecPlan template heading. - '## Artifacts and notes', -] -[default.extend-words] -""" - - -def render_config() -> str: - """Render the full ``typos.toml`` document as a string. - - Returns - ------- - str - The configuration text, ending with a trailing newline. - - Examples - -------- - >>> 'organised = "organized"' in render_config() - True - """ - lines = [HEADER.rstrip("\n")] - lines.extend(f'{word} = "{word}"' for word in EXTRA_ACCEPTED_WORDS) - lines.extend( - entry - for stem in sorted(STEMS) - for ise, ize in SUFFIX_PAIRS - for entry in ( - f'{stem}{ise} = "{stem}{ize}"', - f'{stem}{ize} = "{stem}{ize}"', +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 "\n".join(lines) + "\n" - - -def main(output: Path | None = None) -> None: - """Write the rendered configuration to *output*. - - Parameters - ---------- - output : Path or None - Destination file. Defaults to ``typos.toml`` in the repository - root, resolved relative to this script. - - Examples - -------- - >>> main() # doctest: +SKIP - """ - if output is None: - output = Path(__file__).resolve().parent.parent / "typos.toml" - output.write_text(render_config(), encoding="utf-8") + 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 _tracked_remote_fallback( + source: str | Path, + destination: Path, +) -> rollout.RefreshResult | None: + """Return a valid tracked config only for an unavailable HTTPS authority.""" + if not isinstance(source, str) or urllib.parse.urlsplit(source).scheme != "https": + return None + try: + tomllib.loads(destination.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, tomllib.TOMLDecodeError): + return None + return rollout.RefreshResult("tracked-config", destination) + + +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.""" + destination = output if output is not None else repository / "typos.toml" + try: + result = rollout.refresh_base( + source, + repository / ".typos-oxendict-base.toml", + metadata=repository / ".typos-oxendict-base.json", + offline=offline, + ) + except rollout.NetworkUnavailableError: + fallback = _tracked_remote_fallback(source, destination) + if fallback is not None: + return fallback + raise + rollout.write_config(destination, dictionary_from_cache(repository)) + return result if __name__ == "__main__": - main() + refresh = main() + print(f"{refresh.status}: {REPOSITORY_ROOT / 'typos.toml'}") diff --git a/scripts/tests/test_generate_typos_config.py b/scripts/tests/test_generate_typos_config.py deleted file mode 100644 index 733fff194..000000000 --- a/scripts/tests/test_generate_typos_config.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Unit tests for the ``typos.toml`` generator script. - -The generator is a standalone ``uv run`` script rather than a package -module, so it is loaded here through ``importlib`` from its file path. -""" - -import importlib.util -import pathlib -import tempfile -import tomllib -import types -import typing as typ - -import pytest -from hypothesis import given -from hypothesis import strategies as st - -SCRIPT_PATH = pathlib.Path(__file__).resolve().parents[1] / "generate_typos_config.py" -REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parents[2] -SAFE_FILENAME_CHARS = tuple("abcdefghijklmnopqrstuvwxyz0123456789_-") -SAFE_TYPOS_FILENAMES = st.lists( - st.sampled_from(SAFE_FILENAME_CHARS), - min_size=1, - max_size=32, -).map(lambda chars: f"{''.join(chars)}.toml") - - -@pytest.fixture(name="generator", scope="module") -def generator_fixture() -> types.ModuleType: - """Load the generator script as a module from its file path.""" - spec = importlib.util.spec_from_file_location("generate_typos_config", SCRIPT_PATH) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - assert isinstance(module, types.ModuleType) - return module - - -@pytest.fixture(name="rendered_config", scope="module") -def rendered_config_fixture(generator: types.ModuleType) -> str: - """Render the generator output once for property tests.""" - return typ.cast("str", generator.render_config()) - - -def test_render_config_emits_every_stem_and_suffix_pair( - generator: types.ModuleType, -) -> None: - """Every stem inflection gets an -ise correction and an -ize identity.""" - rendered = generator.render_config() - for stem in generator.STEMS: - for ise, ize in generator.SUFFIX_PAIRS: - assert f'{stem}{ise} = "{stem}{ize}"' in rendered - assert f'{stem}{ize} = "{stem}{ize}"' in rendered - - -def test_render_config_accepts_extra_words_verbatim( - generator: types.ModuleType, -) -> None: - """Every extra accepted word gets an identity entry.""" - rendered = generator.render_config() - for word in generator.EXTRA_ACCEPTED_WORDS: - assert f'{word} = "{word}"' in rendered - - -def test_render_config_ends_with_trailing_newline( - generator: types.ModuleType, -) -> None: - """The rendered document ends with exactly one trailing newline.""" - rendered = generator.render_config() - assert rendered.endswith("\n") - assert not rendered.endswith("\n\n") - - -def test_render_config_parses_as_valid_toml(generator: types.ModuleType) -> None: - """The rendered configuration parses as TOML with no duplicate keys. - - ``tomllib`` raises ``TOMLDecodeError`` on duplicate keys, so parsing guards - against two stem/suffix combinations (or an extra accepted word) colliding - into the same ``extend-words`` entry. The exact entry count additionally - documents that every stem inflection and accepted word is present. - """ - parsed = tomllib.loads(generator.render_config()) - extend_words = parsed["default"]["extend-words"] - expected = len(generator.EXTRA_ACCEPTED_WORDS) + 2 * len(generator.STEMS) * len( - generator.SUFFIX_PAIRS - ) - assert len(extend_words) == expected - - -@given(data=st.data()) -def test_render_config_property_emits_sampled_stem_suffix_pair( - generator: types.ModuleType, - rendered_config: str, - data: st.DataObject, -) -> None: - """A sampled stem and suffix pair gets correction and identity entries.""" - stem = data.draw(st.sampled_from(generator.STEMS)) - ise, ize = data.draw(st.sampled_from(generator.SUFFIX_PAIRS)) - - assert f'{stem}{ise} = "{stem}{ize}"' in rendered_config - assert f'{stem}{ize} = "{stem}{ize}"' in rendered_config - - -@given(data=st.data()) -def test_render_config_property_accepts_sampled_extra_word( - generator: types.ModuleType, - rendered_config: str, - data: st.DataObject, -) -> None: - """A sampled extra accepted word gets an identity entry.""" - word = data.draw(st.sampled_from(sorted(generator.EXTRA_ACCEPTED_WORDS))) - - assert f'{word} = "{word}"' in rendered_config - - -@given(filename=SAFE_TYPOS_FILENAMES) -def test_main_property_writes_rendered_config_without_mutation( - generator: types.ModuleType, - filename: str, -) -> None: - """main() writes rendered content exactly to a sampled safe filename.""" - with tempfile.TemporaryDirectory() as temporary_directory: - output = pathlib.Path(temporary_directory) / filename - generator.main(output) - - assert output.read_text(encoding="utf-8") == generator.render_config() - - -def test_main_writes_rendered_config_to_explicit_path( - generator: types.ModuleType, - tmp_path: pathlib.Path, -) -> None: - """main() writes the rendered configuration to the given output path.""" - output = tmp_path / "typos.toml" - generator.main(output) - assert output.read_text(encoding="utf-8") == generator.render_config() - - -def test_main_default_path_resolves_to_repository_root( - generator: types.ModuleType, - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """main() defaults to typos.toml two levels above the script file.""" - fake_script = tmp_path / "scripts" / "generate_typos_config.py" - monkeypatch.setattr(generator, "__file__", str(fake_script)) - generator.main() - written = tmp_path / "typos.toml" - assert written.read_text(encoding="utf-8") == generator.render_config() - - -def test_committed_config_matches_generator_output( - generator: types.ModuleType, -) -> None: - """The committed typos.toml must not drift from the generator.""" - committed = (REPOSITORY_ROOT / "typos.toml").read_text(encoding="utf-8") - assert committed == generator.render_config() - - -def test_committed_config_parses_as_valid_toml() -> None: - """The committed typos.toml parses as TOML (no syntax or duplicate-key errors).""" - committed = (REPOSITORY_ROOT / "typos.toml").read_text(encoding="utf-8") - parsed = tomllib.loads(committed) - assert parsed["default"]["locale"] == "en-gb" - assert parsed["default"]["extend-words"] diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py new file mode 100644 index 000000000..29d5f167d --- /dev/null +++ b/scripts/tests/test_typos_rollout.py @@ -0,0 +1,395 @@ +"""Tests for the repository spelling-policy scripts.""" + +from __future__ import annotations + +import ast +import email.message +import importlib +import json +import os +import tomllib +import typing as typ +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +if typ.TYPE_CHECKING: + import types + +SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] + + +def test_rollout_scripts_support_python_313() -> None: + """Every rollout script parses with the declared minimum Python version.""" + for script in SCRIPT_DIRECTORY.glob("*.py"): + ast.parse( + script.read_text(encoding="utf-8"), + filename=str(script), + feature_version=(3, 13), + ) + + +@pytest.fixture(name="rollout_modules") +def rollout_modules_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[types.ModuleType, types.ModuleType, types.ModuleType]: + """Import the scripts through the same top-level module path used at runtime.""" + monkeypatch.syspath_prepend(str(SCRIPT_DIRECTORY)) + names = ("typos_rollout_cache", "typos_rollout", "generate_typos_config") + importlib.invalidate_caches() + cache, rollout, generator = (importlib.import_module(name) for name in names) + return cache, rollout, generator + + +def _dictionary_text(stem: str = "organ") -> str: + """Return a minimal valid shared-dictionary document.""" + return ( + 'schema = 1\n\n[oxford]\nstems = ["' + + stem + + '"]\n\n[words]\naccepted = []\n\n[words.corrections]\n\n' + + "[patterns]\nignore = []\n\n[files]\nexclude = []\n" + ) + + +def test_rollout_generates_oxford_corrections( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], +) -> None: + """The shared renderer accepts Oxford forms and corrects plain-British ones.""" + _, rollout, _ = rollout_modules + + mappings = rollout.generate_word_mappings(rollout.Dictionary(stems=("organ",))) + + assert mappings["organize"] == "organize" + assert mappings["organise"] == "organize" + italic_mappings = rollout.generate_word_mappings( + rollout.Dictionary(stems=("italic",)) + ) + assert italic_mappings["italicised"] == "italicized" + + +def test_local_refresh_keeps_a_newer_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """An older local authority cannot replace a newer untracked cache.""" + _, rollout, _ = rollout_modules + source = tmp_path / "shared.toml" + cache = tmp_path / ".typos-base.toml" + metadata = tmp_path / ".typos-base.json" + source.write_text(_dictionary_text(), encoding="utf-8") + source.touch() + rollout.refresh_base(source, cache, metadata=metadata) + cache.write_text(_dictionary_text("newer"), encoding="utf-8") + cache.touch() + source_mtime = source.stat().st_mtime_ns + cache_mtime = max(cache.stat().st_mtime_ns, source_mtime + 1) + os.utime(cache, ns=(cache_mtime, cache_mtime)) + + result = rollout.refresh_base(source, cache, metadata=metadata) + + assert result.status == "current" + assert rollout.load_dictionary(cache).stems == ("newer",) + + +def test_https_failure_reuses_valid_tracked_config( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A clean network-restricted checkout retains its reviewed policy.""" + _, rollout, generator = rollout_modules + tracked_config = tmp_path / "typos.toml" + tracked_config.write_text('[default]\nlocale = "en-gb"\n', encoding="utf-8") + + def unavailable(*_args: object, **_kwargs: object) -> None: + """Model an unavailable HTTPS authority.""" + raise rollout.NetworkUnavailableError("offline") + + monkeypatch.setattr(rollout, "refresh_base", unavailable) + + result = generator.main(repository=tmp_path, source="https://example.invalid/base") + + assert result.status == "tracked-config" + assert result.cache == tracked_config + + +def test_dictionary_validation_rejects_invalid_documents( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Schema, table, string-list and correction types remain validated.""" + _, rollout, _ = rollout_modules + source = tmp_path / "base.toml" + invalid_documents = ( + _dictionary_text().replace("schema = 1", "schema = 2"), + _dictionary_text().replace('[oxford]\nstems = ["organ"]', 'oxford = "bad"'), + _dictionary_text().replace('stems = ["organ"]', "stems = [1]"), + _dictionary_text().replace( + "[words.corrections]", "[words.corrections]\nteh = 1" + ), + ) + + for document in invalid_documents: + source.write_text(document, encoding="utf-8") + with pytest.raises((TypeError, ValueError)): + rollout.load_dictionary(source) + + +def test_merge_rejects_conflicting_corrections( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], +) -> None: + """A local overlay cannot silently weaken a shared correction.""" + _, rollout, _ = rollout_modules + base = rollout.Dictionary(corrections=(("teh", "the"),)) + local = rollout.Dictionary(corrections=(("teh", "ten"),)) + + with pytest.raises(ValueError, match="conflicting correction"): + rollout.merge_dictionaries(base, local) + + +def test_render_and_write_are_deterministic_valid_toml( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Rendering is stable, parseable and atomically installed.""" + _, rollout, _ = rollout_modules + dictionary = rollout.Dictionary( + stems=("organ",), + accepted=("proper-name",), + ignore_patterns=("https?://",), + excluded_files=("target",), + ) + output = tmp_path / "nested" / "typos.toml" + + first = rollout.render_typos_config(dictionary) + rollout.write_config(output, dictionary) + + assert first == rollout.render_typos_config(dictionary) + assert output.read_text(encoding="utf-8") == first + assert tomllib.loads(first)["default"]["locale"] == "en-gb" + assert list(output.parent.glob(".typos.toml.*")) == [] + + +def test_offline_refresh_requires_and_reuses_valid_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Offline mode fails closed before reusing a validated cache.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "base.toml" + metadata = tmp_path / "base.json" + + with pytest.raises(FileNotFoundError, match="no cached shared dictionary"): + rollout.refresh_base( + "https://example.invalid/base", cache, metadata=metadata, offline=True + ) + + cache.write_text(_dictionary_text(), encoding="utf-8") + result = rollout.refresh_base( + "https://example.invalid/base", cache, metadata=metadata, offline=True + ) + + assert result.status == "offline-cache" + + +def test_local_refresh_switches_authority_and_records_metadata( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """A different explicit authority replaces a cache regardless of mtime.""" + _, rollout, _ = rollout_modules + first = tmp_path / "first.toml" + second = tmp_path / "second.toml" + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + first.write_text(_dictionary_text("first"), encoding="utf-8") + second.write_text(_dictionary_text("second"), encoding="utf-8") + os.utime(first, ns=(3_000_000_000, 3_000_000_000)) + os.utime(second, ns=(1_000_000_000, 1_000_000_000)) + rollout.refresh_base(first, cache, metadata=metadata) + + result = rollout.refresh_base(second, cache, metadata=metadata) + + assert result.status == "refreshed" + assert rollout.load_dictionary(cache).stems == ("second",) + assert json.loads(metadata.read_text(encoding="utf-8"))["source"] == str( + second.resolve() + ) + + +def test_http_refresh_scopes_validators_and_preserves_newer_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Remote refresh reuses validators only for their original source.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + requests: list[urllib.request.Request] = [] + + class Response: + """Provide the HTTP response surface consumed by the helper.""" + + status = 200 + headers: typ.ClassVar[dict[str, str]] = { + "ETag": '"estate-v1"', + "Last-Modified": "Fri, 10 Jul 2026 08:00:00 GMT", + } + + def read(self) -> bytes: + """Return a valid shared dictionary.""" + return _dictionary_text().encode() + + def __enter__(self) -> Response: + """Enter the fake response context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the fake response context.""" + + def open_response(request: urllib.request.Request, *, timeout: float) -> Response: + """Capture the request passed to the network boundary.""" + assert timeout == pytest.approx(30.0) + requests.append(request) + return Response() + + monkeypatch.setattr(rollout.urllib.request, "urlopen", open_response) + + first = rollout.refresh_base( + "https://example.test/base.toml", cache, metadata=metadata + ) + second = rollout.refresh_base( + "https://example.test/base.toml", cache, metadata=metadata + ) + replacement = rollout.refresh_base( + "https://example.test/replacement.toml", cache, metadata=metadata + ) + + assert first.status == "refreshed" + assert second.status == "current" + assert requests[1].get_header("If-none-match") == '"estate-v1"' + assert replacement.status == "refreshed" + assert requests[2].get_header("If-none-match") is None + assert requests[2].get_header("If-modified-since") is None + + +def test_remote_failure_reuses_only_a_valid_stale_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Network failure keeps validated data and propagates without it.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + + def fail(*_args: object, **_kwargs: object) -> None: + """Model an unavailable remote authority.""" + message = "offline" + raise urllib.error.URLError(message) + + monkeypatch.setattr(rollout.urllib.request, "urlopen", fail) + + with pytest.raises(rollout.NetworkUnavailableError): + rollout.refresh_base("https://example.test/base", cache, metadata=metadata) + + cache.write_text(_dictionary_text(), encoding="utf-8") + result = rollout.refresh_base("https://example.test/base", cache, metadata=metadata) + + assert result.status == "stale-cache" + + +def test_remote_refresh_rejects_insecure_and_invalid_content( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """The remote boundary requires HTTPS and validates bytes before install.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + + with pytest.raises(ValueError, match="must use HTTPS"): + rollout.refresh_base("http://example.test/base", cache, metadata=metadata) + + class InvalidResponse: + """Return malformed TOML from an otherwise successful response.""" + + status = 200 + headers: typ.ClassVar[dict[str, str]] = {} + + def read(self) -> bytes: + """Return malformed bytes.""" + return b"not = [valid" + + def __enter__(self) -> InvalidResponse: + """Enter the fake response context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the fake response context.""" + + monkeypatch.setattr( + rollout.urllib.request, "urlopen", lambda *_args, **_kwargs: InvalidResponse() + ) + + with pytest.raises(tomllib.TOMLDecodeError): + rollout.refresh_base("https://example.test/base", cache, metadata=metadata) + assert not cache.exists() + + +def test_metadata_reader_handles_invalid_and_non_object_json( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Malformed or non-object freshness metadata is safely ignored.""" + _, rollout, _ = rollout_modules + metadata = tmp_path / "cache.json" + + metadata.write_text("not-json", encoding="utf-8") + assert rollout._read_metadata(metadata) == {} + metadata.write_text("[]", encoding="utf-8") + assert rollout._read_metadata(metadata) == {} + + +def test_http_error_translation_handles_not_modified_and_stale_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """HTTP status handling distinguishes current, stale and absent data.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + cache.write_text(_dictionary_text(), encoding="utf-8") + headers = email.message.Message() + not_modified = urllib.error.HTTPError( + "https://example.test/base", 304, "not modified", headers, None + ) + unavailable = urllib.error.HTTPError( + "https://example.test/base", 503, "unavailable", headers, None + ) + + assert rollout._http_error_result(cache, not_modified).status == "current" + with pytest.raises(urllib.error.HTTPError): + rollout._http_error_result(cache, unavailable) + cache.unlink() + with pytest.raises(urllib.error.HTTPError): + rollout._http_error_result(cache, unavailable) + + +def test_remote_freshness_uses_dates_and_falls_back_on_invalid_values( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], +) -> None: + """Last-Modified comparison remains conservative for malformed dates.""" + _, rollout, _ = rollout_modules + + assert rollout._remote_is_not_newer( + {"last_modified": "Fri, 10 Jul 2026 08:00:00 GMT"}, + {"Last-Modified": "Fri, 10 Jul 2026 07:00:00 GMT"}, + ) + assert rollout._remote_is_not_newer( + {"last_modified": "invalid"}, {"Last-Modified": "invalid"} + ) + assert not rollout._remote_is_not_newer({}, {"Last-Modified": "invalid"}) diff --git a/scripts/tests/test_typos_rollout_hardening.py b/scripts/tests/test_typos_rollout_hardening.py new file mode 100644 index 000000000..81250dd16 --- /dev/null +++ b/scripts/tests/test_typos_rollout_hardening.py @@ -0,0 +1,351 @@ +"""Focused regressions for hardened shared spelling-policy refreshes.""" + +from __future__ import annotations + +import importlib +import json +import types +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] + + +def _dictionary_text(stem: str = "organ") -> str: + """Return a minimal valid shared-dictionary document.""" + return ( + 'schema = 1\n\n[oxford]\nstems = ["' + + stem + + '"]\n\n[words]\naccepted = []\n\n[words.corrections]\n\n' + + "[patterns]\nignore = []\n\n[files]\nexclude = []\n" + ) + + +class ValidResponse: + """Provide configurable valid dictionary bytes at the HTTP boundary.""" + + status = 200 + + def __init__( + self, + *, + stem: str = "organ", + headers: dict[str, str] | None = None, + ) -> None: + self._stem = stem + self.headers = {} if headers is None else headers + + def read(self) -> bytes: + """Return valid shared dictionary bytes.""" + return _dictionary_text(self._stem).encode() + + def __enter__(self) -> ValidResponse: + """Enter the fake response context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the fake response context.""" + + +@pytest.fixture(name="rollout_modules") +def rollout_modules_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, +]: + """Import helpers through the top-level paths used by the generator.""" + monkeypatch.syspath_prepend(str(SCRIPT_DIRECTORY)) + names = ( + "typos_rollout_cache", + "typos_rollout_http", + "typos_rollout", + "generate_typos_config", + ) + importlib.invalidate_caches() + cache, refresh, rollout, generator = ( + importlib.import_module(name) for name in names + ) + return cache, refresh, rollout, generator + + +def test_oxford_adverb_suffix_is_generated( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], +) -> None: + """Oxford adverbs are accepted and plain-British forms are corrected.""" + _, _, rollout, _ = rollout_modules + mappings = rollout.generate_word_mappings(rollout.Dictionary(stems=("recogn",))) + + assert mappings["recognizably"] == "recognizably" + assert mappings["recognisably"] == "recognizably" + + +def test_changed_etag_overrides_unchanged_date( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], + tmp_path: Path, +) -> None: + """A changed ETag refreshes even when Last-Modified is unchanged.""" + _, _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + source = "https://example.test/base.toml" + modified = "Fri, 10 Jul 2026 08:00:00 GMT" + cache.write_text(_dictionary_text("original"), encoding="utf-8") + metadata.write_text( + json.dumps( + { + "etag": '"estate-v1"', + "last_modified": modified, + "source": source, + } + ), + encoding="utf-8", + ) + + result = rollout.refresh_base( + source, + cache, + metadata=metadata, + opener=lambda *_args, **_kwargs: ValidResponse( + stem="replacement", + headers={"ETag": '"estate-v2"', "Last-Modified": modified}, + ), + ) + + assert result.status == "refreshed" + assert rollout.load_dictionary(cache).stems == ("replacement",) + + +@pytest.mark.parametrize( + "target", + ["http://example.test/base.toml", "ftp://example.test/base.toml"], +) +def test_redirect_target_requires_https( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], + target: str, +) -> None: + """Redirects cannot downgrade or leave HTTPS transport.""" + _, refresh, _, _ = rollout_modules + handler = refresh._HttpsRedirectHandler() + + with pytest.raises( + refresh.InsecureSourceError, + match="redirect must use HTTPS", + ): + handler.redirect_request( + urllib.request.Request("https://example.test/base.toml"), + None, + 302, + "Found", + {}, + target, + ) + + +def test_default_refresh_uses_guarded_https_opener( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Production refresh delegates through the guarded redirect opener.""" + _, refresh, rollout, _ = rollout_modules + requests: list[object] = [] + + class GuardedOpener: + """Capture calls through the configured HTTPS-only opener.""" + + def open(self, request: object, *, timeout: float) -> ValidResponse: + """Return a valid response after recording the guarded call.""" + assert timeout == pytest.approx(30.0) + requests.append(request) + return ValidResponse() + + monkeypatch.setattr(refresh, "_HTTPS_OPENER", GuardedOpener()) + + result = rollout.refresh_base( + "https://example.test/base.toml", + tmp_path / "cache.toml", + metadata=tmp_path / "cache.json", + ) + + assert result.status == "refreshed" + assert len(requests) == 1 + + +def test_http_status_and_persistence_errors_propagate( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """HTTP status and local write failures never become stale successes.""" + cache_module, _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + cache.write_text(_dictionary_text(), encoding="utf-8") + not_found = urllib.error.HTTPError( + "https://example.test/missing", + 404, + "Not Found", + hdrs=None, + fp=None, + ) + + def missing(*_args: object, **_kwargs: object) -> ValidResponse: + """Raise the authority's HTTP response.""" + raise not_found + + with pytest.raises(urllib.error.HTTPError) as raised: + rollout.refresh_base( + "https://example.test/missing", + cache, + metadata=metadata, + opener=missing, + ) + assert raised.value is not_found + + def denied(*_args: object, **_kwargs: object) -> None: + """Model denied persistence at the atomic-write boundary.""" + raise PermissionError("cache is read-only") + + monkeypatch.setattr(cache_module, "atomic_write", denied) + with pytest.raises(PermissionError, match="cache is read-only"): + rollout.refresh_base( + "https://example.test/base", + cache, + metadata=metadata, + opener=lambda *_args, **_kwargs: ValidResponse(stem="replacement"), + ) + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + urllib.error.HTTPError( + "https://example.test/missing", + 404, + "Not Found", + hdrs=None, + fp=None, + ), + id="http-status", + ), + pytest.param(PermissionError("cache is read-only"), id="persistence"), + ], +) +def test_generator_propagates_non_connectivity_failures( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + error: OSError, +) -> None: + """Generator fallback is limited to connectivity-domain failures.""" + _, _, rollout, generator = rollout_modules + (tmp_path / "typos.toml").write_text( + '[default]\nlocale = "en-gb"\n', + encoding="utf-8", + ) + + def fail(*_args: object, **_kwargs: object) -> None: + """Raise the selected non-connectivity failure.""" + raise error + + monkeypatch.setattr(rollout, "refresh_base", fail) + with pytest.raises(type(error)) as raised: + generator.main( + repository=tmp_path, + source="https://example.test/base", + ) + + assert raised.value is error + + +@pytest.mark.parametrize("failure_stage", ["write", "close", "replace"]) +def test_atomic_write_cleans_temporary_file_after_failure( + rollout_modules: tuple[ + types.ModuleType, + types.ModuleType, + types.ModuleType, + types.ModuleType, + ], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + failure_stage: str, +) -> None: + """Write, close, and replacement failures remove the temporary file.""" + cache_module, _, _, _ = rollout_modules + temporary = tmp_path / ".typos.toml.failure" + temporary.touch() + + class FailingStream: + """Model a named temporary stream with one selected failure.""" + + name = str(temporary) + + def __enter__(self) -> FailingStream: + """Enter the fake stream context.""" + return self + + def write(self, content: bytes) -> None: + """Write bytes unless this case models a write failure.""" + if failure_stage == "write": + raise OSError("write failure") + temporary.write_bytes(content) + + def __exit__(self, *_args: object) -> None: + """Close unless this case models a close failure.""" + if failure_stage == "close": + raise OSError("close failure") + + monkeypatch.setattr( + cache_module.tempfile, + "NamedTemporaryFile", + lambda **_kwargs: FailingStream(), + ) + if failure_stage == "replace": + + def fail_replace(_path: Path, _destination: Path) -> None: + """Model an atomic replacement failure.""" + raise OSError("replace failure") + + monkeypatch.setattr(cache_module.pathlib.Path, "replace", fail_replace) + + with pytest.raises(OSError, match=f"{failure_stage} failure"): + cache_module.atomic_write(tmp_path / "typos.toml", b"content") + + assert not temporary.exists() diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py new file mode 100644 index 000000000..de9135436 --- /dev/null +++ b/scripts/typos_rollout.py @@ -0,0 +1,237 @@ +"""Refresh and render shared en-GB-oxendict ``typos`` configuration.""" + +from __future__ import annotations + +import dataclasses as dc +import json +import pathlib +import tomllib +import typing as typ +import urllib.error +import urllib.parse +import urllib.request + +import typos_rollout_cache +import typos_rollout_http + +if typ.TYPE_CHECKING: + import collections.abc as cabc + +RefreshResult = typos_rollout_cache.RefreshResult +_CacheTargets = typos_rollout_cache.CacheTargets +_RemoteResponse = typos_rollout_cache.RemoteResponse +NetworkUnavailableError = typos_rollout_http.NetworkUnavailableError +InsecureSourceError = typos_rollout_http.InsecureSourceError +_HttpsRedirectHandler = typos_rollout_http._HttpsRedirectHandler +_HTTPS_OPENER = typos_rollout_http._HTTPS_OPENER +_read_metadata = typos_rollout_http._read_metadata +_remote_is_not_newer = typos_rollout_http._remote_is_not_newer +_atomic_write = typos_rollout_cache.atomic_write +_DEFAULT_URLOPEN = urllib.request.urlopen + +SCHEMA_VERSION = 1 +HTTP_NOT_MODIFIED = 304 +SUFFIX_PAIRS = ( + ("isably", "izably"), + ("ise", "ize"), + ("ises", "izes"), + ("ised", "ized"), + ("ising", "izing"), + ("iser", "izer"), + ("isers", "izers"), + ("isable", "izable"), + ("isation", "ization"), + ("isations", "izations"), +) + + +@dc.dataclass(frozen=True) +class Dictionary: + """Curated words and exclusions used to generate a ``typos`` config.""" + + stems: tuple[str, ...] = () + accepted: tuple[str, ...] = () + corrections: tuple[tuple[str, str], ...] = () + ignore_patterns: tuple[str, ...] = () + excluded_files: tuple[str, ...] = () + + +def _string_list(table: cabc.Mapping[str, object], key: str) -> tuple[str, ...]: + """Read and validate a list of strings from a TOML table.""" + value = table.get(key, []) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + message = f"{key!r} must be a list of strings" + raise TypeError(message) + return tuple(sorted(set(value))) + + +def _table(document: cabc.Mapping[str, object], key: str) -> cabc.Mapping[str, object]: + """Read and validate a TOML table.""" + value = document.get(key, {}) + if not isinstance(value, dict): + message = f"{key!r} must be a table" + raise TypeError(message) + return typ.cast("cabc.Mapping[str, object]", value) + + +def _dictionary_from_text(text: str) -> Dictionary: + """Parse and validate shared dictionary text.""" + document = tomllib.loads(text) + schema = document.get("schema") + if schema != SCHEMA_VERSION: + message = f"unsupported dictionary schema {schema!r}" + raise ValueError(message) + oxford = _table(document, "oxford") + words = _table(document, "words") + patterns = _table(document, "patterns") + files = _table(document, "files") + corrections_table = _table(words, "corrections") + if not all( + isinstance(key, str) and isinstance(value, str) + for key, value in corrections_table.items() + ): + message = "word corrections must map strings to strings" + raise TypeError(message) + corrections = typ.cast("cabc.Mapping[str, str]", corrections_table) + return Dictionary( + stems=_string_list(oxford, "stems"), + accepted=_string_list(words, "accepted"), + corrections=tuple(sorted(corrections.items())), + ignore_patterns=_string_list(patterns, "ignore"), + excluded_files=_string_list(files, "exclude"), + ) + + +def load_dictionary(path: pathlib.Path) -> Dictionary: + """Load a validated shared dictionary from *path*.""" + return _dictionary_from_text(path.read_text(encoding="utf-8")) + + +def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: + """Merge a shared dictionary with a non-conflicting local overlay.""" + corrections = dict(base.corrections) + for word, correction in local.corrections: + existing = corrections.get(word) + if existing is not None and existing != correction: + message = ( + f"conflicting correction for {word!r}: {existing!r} != {correction!r}" + ) + raise ValueError(message) + corrections[word] = correction + return Dictionary( + stems=tuple(sorted(set(base.stems) | set(local.stems))), + accepted=tuple(sorted(set(base.accepted) | set(local.accepted))), + corrections=tuple(sorted(corrections.items())), + ignore_patterns=tuple( + sorted(set(base.ignore_patterns) | set(local.ignore_patterns)) + ), + excluded_files=tuple( + sorted(set(base.excluded_files) | set(local.excluded_files)) + ), + ) + + +def generate_word_mappings(dictionary: Dictionary) -> dict[str, str]: + """Expand Oxford stems and explicit words into deterministic mappings.""" + mappings = {word: word for word in dictionary.accepted} + + def add(word: str, correction: str) -> None: + existing = mappings.get(word) + if existing is not None and existing != correction: + message = ( + f"conflicting generated correction for {word!r}: " + f"{existing!r} != {correction!r}" + ) + raise ValueError(message) + mappings[word] = correction + + for word, correction in dictionary.corrections: + add(word, correction) + for stem in dictionary.stems: + for plain_british, oxford in SUFFIX_PAIRS: + add(f"{stem}{plain_british}", f"{stem}{oxford}") + add(f"{stem}{oxford}", f"{stem}{oxford}") + return dict(sorted(mappings.items())) + + +def _toml_string(value: str) -> str: + """Render a string using TOML-compatible JSON quoting.""" + return json.dumps(value, ensure_ascii=False) + + +def _render_array(name: str, values: tuple[str, ...]) -> list[str]: + """Render a deterministic TOML string array.""" + lines = [f"{name} = ["] + lines.extend(f" {_toml_string(value)}," for value in values) + lines.append("]") + return lines + + +def render_typos_config(dictionary: Dictionary) -> str: + """Render a deterministic, parse-checked ``typos.toml`` document.""" + lines = [ + "# Generated from the shared en-GB-oxendict dictionary.", + "# Regenerate with scripts/generate_typos_config.py; do not edit by hand.", + "", + "[files]", + *_render_array("extend-exclude", dictionary.excluded_files), + "", + "[default]", + 'locale = "en-gb"', + *_render_array("extend-ignore-re", dictionary.ignore_patterns), + "", + "[default.extend-words]", + ] + lines.extend( + f"{_toml_string(word)} = {_toml_string(correction)}" + for word, correction in generate_word_mappings(dictionary).items() + ) + rendered = "\n".join(lines) + "\n" + tomllib.loads(rendered) + return rendered + + +def write_config(path: pathlib.Path, dictionary: Dictionary) -> None: + """Atomically write validated generated configuration to *path*.""" + _atomic_write(path, render_typos_config(dictionary).encode()) + + +def _validate_dictionary_bytes(content: bytes) -> None: + """Reject bytes that do not contain a valid shared dictionary.""" + _dictionary_from_text(content.decode()) + + +def _http_error_result( + cache: pathlib.Path, + error: urllib.error.HTTPError, +) -> RefreshResult: + """Preserve the HTTP-status helper at the compatibility boundary.""" + return typos_rollout_http._http_error_result( + cache, + error, + _validate_dictionary_bytes, + ) + + +def refresh_base( + source: str | pathlib.Path, + cache: pathlib.Path, + *, + metadata: pathlib.Path, + offline: bool = False, + opener: typ.Callable[..., _RemoteResponse] | None = None, +) -> RefreshResult: + """Refresh an untracked base cache when the authoritative copy is newer.""" + selected_opener = opener + if selected_opener is None and urllib.request.urlopen is not _DEFAULT_URLOPEN: + # Existing focused tests patch this legacy boundary; production retains + # the HTTPS-only opener when the standard function is unchanged. + selected_opener = urllib.request.urlopen + return typos_rollout_http.refresh_base( + source, + cache, + metadata=metadata, + validate=_validate_dictionary_bytes, + offline=offline, + opener=selected_opener, + ) diff --git a/scripts/typos_rollout_cache.py b/scripts/typos_rollout_cache.py new file mode 100644 index 000000000..9e4479e0f --- /dev/null +++ b/scripts/typos_rollout_cache.py @@ -0,0 +1,53 @@ +"""Provide cache support types and atomic writes for the spelling helper.""" + +from __future__ import annotations + +import dataclasses as dc +import pathlib +import tempfile +import typing as typ + +if typ.TYPE_CHECKING: + import collections.abc as cabc + + +@dc.dataclass(frozen=True) +class RefreshResult: + """Describe whether the untracked shared dictionary cache changed.""" + + status: str + cache: pathlib.Path + + +@dc.dataclass(frozen=True) +class CacheTargets: + """Group the untracked dictionary cache and metadata sidecar paths.""" + + cache: pathlib.Path + metadata: pathlib.Path + + +class RemoteResponse(typ.Protocol): + """Expose the HTTP response surface used by cache refresh.""" + + status: int + headers: cabc.Mapping[str, str] + + def read(self) -> bytes: + """Read the response body.""" + ... + + +def atomic_write(path: pathlib.Path, content: bytes) -> None: + """Write content beside a path and atomically replace the destination.""" + path.parent.mkdir(parents=True, exist_ok=True) + stream = tempfile.NamedTemporaryFile( + delete=False, dir=path.parent, prefix=f".{path.name}." + ) + temporary = pathlib.Path(stream.name) + try: + with stream: + stream.write(content) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) diff --git a/scripts/typos_rollout_http.py b/scripts/typos_rollout_http.py new file mode 100644 index 000000000..c4d224474 --- /dev/null +++ b/scripts/typos_rollout_http.py @@ -0,0 +1,340 @@ +"""Refresh the shared spelling dictionary across local and HTTPS authorities. + +This module owns cache freshness, transport security, and persistence +coordination. The renderer remains in :mod:`typos_rollout`; callers outside +the spelling helper should not reuse these infrastructure internals. +""" + +from __future__ import annotations + +import email.utils +import json +import pathlib +import urllib.error +import urllib.parse +import urllib.request +from collections import abc as cabc + +import typos_rollout_cache + +ContentValidator = cabc.Callable[[bytes], None] +Opener = cabc.Callable[..., typos_rollout_cache.RemoteResponse] +HTTP_NOT_MODIFIED = 304 + + +class NetworkUnavailableError(OSError): + """Report that the remote dictionary authority could not be reached.""" + + +class InsecureSourceError(ValueError): + """Report a dictionary source or redirect that does not use HTTPS.""" + + +def _read_metadata(path: pathlib.Path) -> dict[str, object]: + """Read best-effort HTTP freshness metadata.""" + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def _write_metadata( + path: pathlib.Path, + metadata: cabc.Mapping[str, object], +) -> None: + """Atomically write HTTP freshness metadata.""" + content = (json.dumps(metadata, sort_keys=True) + "\n").encode() + typos_rollout_cache.atomic_write(path, content) + + +def _valid_cache(cache: pathlib.Path, validate: ContentValidator) -> bool: + """Return whether *cache* contains a valid shared dictionary.""" + try: + validate(cache.read_bytes()) + except ( + FileNotFoundError, + OSError, + TypeError, + ValueError, + ): + return False + return True + + +def _remote_is_not_newer( + saved: cabc.Mapping[str, object], + headers: cabc.Mapping[str, str], +) -> bool: + """Return whether HTTP validators prove the response is not newer.""" + etag = headers.get("ETag") + saved_etag = saved.get("etag") + if isinstance(etag, str) and isinstance(saved_etag, str): + return etag == saved_etag + modified = headers.get("Last-Modified") + saved_modified = saved.get("last_modified") + if not isinstance(modified, str) or not isinstance(saved_modified, str): + return False + try: + return email.utils.parsedate_to_datetime( + modified + ) <= email.utils.parsedate_to_datetime(saved_modified) + except (TypeError, ValueError): + return modified == saved_modified + + +def _local_cache_is_current( + cache: pathlib.Path, + saved: cabc.Mapping[str, object], + source_name: str, + source_mtime_ns: int, + validate: ContentValidator, +) -> bool: + """Return whether metadata proves a valid local-source cache is current.""" + saved_mtime = saved.get("mtime_ns") + has_matching_source = saved.get("source") == source_name + has_new_enough_mtime = ( + isinstance(saved_mtime, int) and source_mtime_ns <= saved_mtime + ) + return ( + _valid_cache(cache, validate) and has_matching_source and has_new_enough_mtime + ) + + +def _refresh_local( + source: pathlib.Path, + cache: pathlib.Path, + metadata: pathlib.Path, + validate: ContentValidator, +) -> typos_rollout_cache.RefreshResult: + """Refresh from a local authoritative copy when it is newer.""" + source_stat = source.stat() + source_name = str(source.resolve()) + saved = _read_metadata(metadata) + if _local_cache_is_current( + cache, + saved, + source_name, + source_stat.st_mtime_ns, + validate, + ): + return typos_rollout_cache.RefreshResult("current", cache) + content = source.read_bytes() + validate(content) + typos_rollout_cache.atomic_write(cache, content) + _write_metadata( + metadata, + {"source": source_name, "mtime_ns": source_stat.st_mtime_ns}, + ) + return typos_rollout_cache.RefreshResult("refreshed", cache) + + +def _conditional_headers(saved: cabc.Mapping[str, object]) -> dict[str, str]: + """Build conditional HTTP headers from persisted validators.""" + headers: dict[str, str] = {} + etag = saved.get("etag") + if isinstance(etag, str): + headers["If-None-Match"] = etag + last_modified = saved.get("last_modified") + if isinstance(last_modified, str): + headers["If-Modified-Since"] = last_modified + return headers + + +class _HttpsRedirectHandler(urllib.request.HTTPRedirectHandler): + """Reject redirects that leave the HTTPS transport boundary.""" + + def redirect_request( + self, + request: urllib.request.Request, + file_pointer: object, + code: int, + message: str, + headers: object, + new_url: str, + ) -> urllib.request.Request | None: + """Follow only redirects whose resolved target remains HTTPS.""" + if urllib.parse.urlsplit(new_url).scheme != "https": + error_message = f"shared dictionary redirect must use HTTPS: {new_url}" + raise InsecureSourceError(error_message) + return super().redirect_request( + request, + file_pointer, + code, + message, + headers, + new_url, + ) + + +_HTTPS_OPENER = urllib.request.build_opener(_HttpsRedirectHandler()) + + +def _https_request( + source: str, + headers: cabc.Mapping[str, str], +) -> urllib.request.Request: + """Build a request after constraining the shared source to HTTPS.""" + if urllib.parse.urlsplit(source).scheme != "https": + message = f"shared dictionary URL must use HTTPS: {source}" + raise InsecureSourceError(message) + return urllib.request.Request(source, headers=dict(headers)) + + +def _write_remote_cache( + source: str, + targets: typos_rollout_cache.CacheTargets, + content: bytes, + headers: cabc.Mapping[str, str], + validate: ContentValidator, +) -> typos_rollout_cache.RefreshResult: + """Validate and atomically persist an HTTP dictionary response.""" + validate(content) + typos_rollout_cache.atomic_write(targets.cache, content) + _write_metadata( + targets.metadata, + { + "source": source, + "etag": headers.get("ETag"), + "last_modified": headers.get("Last-Modified"), + }, + ) + return typos_rollout_cache.RefreshResult("refreshed", targets.cache) + + +def _remote_response_result( + source: str, + targets: typos_rollout_cache.CacheTargets, + saved: cabc.Mapping[str, object], + response: typos_rollout_cache.RemoteResponse, + validate: ContentValidator, +) -> typos_rollout_cache.RefreshResult: + """Return the cache result for a successful HTTP response.""" + if response.status == HTTP_NOT_MODIFIED and _valid_cache(targets.cache, validate): + return typos_rollout_cache.RefreshResult("current", targets.cache) + if _valid_cache(targets.cache, validate) and _remote_is_not_newer( + saved, response.headers + ): + return typos_rollout_cache.RefreshResult("current", targets.cache) + try: + content = response.read() + except urllib.error.URLError as error: + message = f"shared dictionary authority is unavailable: {source}" + raise NetworkUnavailableError(message) from error + return _write_remote_cache( + source, + targets, + content, + response.headers, + validate, + ) + + +def _stale_cache_or_raise( + cache: pathlib.Path, + error: NetworkUnavailableError, + validate: ContentValidator, +) -> typos_rollout_cache.RefreshResult: + """Return a valid stale cache or propagate the connectivity failure.""" + if _valid_cache(cache, validate): + return typos_rollout_cache.RefreshResult("stale-cache", cache) + raise error + + +def _http_error_result( + cache: pathlib.Path, + error: urllib.error.HTTPError, + validate: ContentValidator, +) -> typos_rollout_cache.RefreshResult: + """Translate an HTTP response into a current result or propagate it.""" + if error.code == HTTP_NOT_MODIFIED and _valid_cache(cache, validate): + return typos_rollout_cache.RefreshResult("current", cache) + raise error + + +def _refresh_http( + source: str, + cache: pathlib.Path, + metadata: pathlib.Path, + validate: ContentValidator, + opener: Opener | None, +) -> typos_rollout_cache.RefreshResult: + """Refresh a cache from a validated HTTPS source with stale fallback.""" + saved = _read_metadata(metadata) + if saved.get("source") != source: + saved = {} + request = _https_request(source, _conditional_headers(saved)) + open_remote = _HTTPS_OPENER.open if opener is None else opener + try: + response_context = open_remote(request, timeout=30.0) + except urllib.error.HTTPError as error: + return _http_error_result(cache, error, validate) + except urllib.error.URLError: + message = f"shared dictionary authority is unavailable: {source}" + unavailable = NetworkUnavailableError(message) + return _stale_cache_or_raise(cache, unavailable, validate) + with response_context as response: + try: + return _remote_response_result( + source, + typos_rollout_cache.CacheTargets(cache, metadata), + saved, + response, + validate, + ) + except NetworkUnavailableError as error: + return _stale_cache_or_raise(cache, error, validate) + + +def refresh_base( + source: str | pathlib.Path, + cache: pathlib.Path, + *, + metadata: pathlib.Path, + validate: ContentValidator, + offline: bool = False, + opener: Opener | None = None, +) -> typos_rollout_cache.RefreshResult: + """Refresh an untracked cache when its authoritative copy is newer. + + Parameters + ---------- + source + Local path or HTTPS URL for the authoritative shared dictionary. + cache + Untracked local cache destination. + metadata + Sidecar path containing source identity and freshness validators. + validate + Callback that rejects invalid dictionary bytes. + offline + Reuse a valid cache without contacting the authority. + opener + Injectable HTTPS opener. The guarded production opener is the default. + + Returns + ------- + typos_rollout_cache.RefreshResult + Refresh status and validated cache path. + + Raises + ------ + FileNotFoundError + If offline mode has no valid cache. + NetworkUnavailableError + If the authority is unreachable and no valid stale cache exists. + InsecureSourceError + If an authority or redirect does not use HTTPS. + OSError, urllib.error.HTTPError + If local persistence fails or the authority returns an HTTP error. + TypeError, ValueError + If dictionary validation fails. + """ + if offline: + if not _valid_cache(cache, validate): + message = f"no cached shared dictionary at {cache}" + raise FileNotFoundError(message) + return typos_rollout_cache.RefreshResult("offline-cache", cache) + if isinstance(source, pathlib.Path) or "://" not in str(source): + return _refresh_local(pathlib.Path(source), cache, metadata, validate) + return _refresh_http(str(source), cache, metadata, validate, opener) diff --git a/typos.local.toml b/typos.local.toml new file mode 100644 index 000000000..c0fdd7486 --- /dev/null +++ b/typos.local.toml @@ -0,0 +1,18 @@ +# Repository-specific identifiers, quotations and deliberate fixtures. + +schema = 1 + +[oxford] +stems = [] + +[words] +accepted = [] + +[words.corrections] + +[patterns] +# The tokenizer splits the valid hyphenated prefix in `mis-grouping`. +ignore = ["mis-grouping"] + +[files] +exclude = [] diff --git a/typos.toml b/typos.toml index 7ad27a0c3..409e554b6 100644 --- a/typos.toml +++ b/typos.toml @@ -1,1808 +1,2988 @@ -# typos configuration enforcing en-GB-oxendict (Oxford spelling). -# -# Generated by scripts/generate_typos_config.py; regenerate rather than -# editing entries by hand: -# -# uv run scripts/generate_typos_config.py -# -# The en-gb locale corrects American spellings (color -> colour, -# behavior -> behaviour, analyzed -> analysed) but prefers -ise over the -# Oxford -ize. The extend-words table below restores Oxford spelling: -# identity entries accept -ize inflections, and -ise entries are -# corrected to -ize. +# Generated from the shared en-GB-oxendict dictionary. +# Regenerate with scripts/generate_typos_config.py; do not edit by hand. [files] extend-exclude = [ ".git", - ".venv", + ".hypothesis", + ".pytest_cache", + ".terraform", + ".tox", + ".typos-oxendict-base.json", + ".typos-oxendict-base.toml", ".uv-cache", ".uv-tools", - "target", - "node_modules", + ".venv", "Cargo.lock", - "CRUSH.md", + "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", ] [default] locale = "en-gb" extend-ignore-re = [ - # Inline code spans and fenced code blocks quote APIs and identifiers, - # which keep their upstream spelling (for example `color`, `flavor`). - '`[^`\n]+`', - '(?s)```.*?```', - # Tool and Makefile target names that keep their upstream spelling - # even when they appear outside code spans. - 'rust-analyzer', - 'release-artifact', - # ExecPlan template heading. - '## Artifacts and notes', + "(?s)```.*?```", + "\\brust-analyzer\\b", + "`[^`\\n]+`", + "mis-grouping", ] [default.extend-words] -yse = "yse" -mis = "mis" -inventario = "inventario" -anonymise = "anonymize" -anonymize = "anonymize" -anonymises = "anonymizes" -anonymizes = "anonymizes" -anonymised = "anonymized" -anonymized = "anonymized" -anonymising = "anonymizing" -anonymizing = "anonymizing" -anonymiser = "anonymizer" -anonymizer = "anonymizer" -anonymisers = "anonymizers" -anonymizers = "anonymizers" -anonymisable = "anonymizable" -anonymizable = "anonymizable" -anonymisation = "anonymization" -anonymization = "anonymization" -anonymisations = "anonymizations" -anonymizations = "anonymizations" -apologise = "apologize" -apologize = "apologize" -apologises = "apologizes" -apologizes = "apologizes" -apologised = "apologized" -apologized = "apologized" -apologising = "apologizing" -apologizing = "apologizing" -apologiser = "apologizer" -apologizer = "apologizer" -apologisers = "apologizers" -apologizers = "apologizers" -apologisable = "apologizable" -apologizable = "apologizable" -apologisation = "apologization" -apologization = "apologization" -apologisations = "apologizations" -apologizations = "apologizations" -authorise = "authorize" -authorize = "authorize" -authorises = "authorizes" -authorizes = "authorizes" -authorised = "authorized" -authorized = "authorized" -authorising = "authorizing" -authorizing = "authorizing" -authoriser = "authorizer" -authorizer = "authorizer" -authorisers = "authorizers" -authorizers = "authorizers" -authorisable = "authorizable" -authorizable = "authorizable" -authorisation = "authorization" -authorization = "authorization" -authorisations = "authorizations" -authorizations = "authorizations" -canonicalise = "canonicalize" -canonicalize = "canonicalize" -canonicalises = "canonicalizes" -canonicalizes = "canonicalizes" -canonicalised = "canonicalized" -canonicalized = "canonicalized" -canonicalising = "canonicalizing" -canonicalizing = "canonicalizing" -canonicaliser = "canonicalizer" -canonicalizer = "canonicalizer" -canonicalisers = "canonicalizers" -canonicalizers = "canonicalizers" -canonicalisable = "canonicalizable" -canonicalizable = "canonicalizable" -canonicalisation = "canonicalization" -canonicalization = "canonicalization" -canonicalisations = "canonicalizations" -canonicalizations = "canonicalizations" -capitalise = "capitalize" -capitalize = "capitalize" -capitalises = "capitalizes" -capitalizes = "capitalizes" -capitalised = "capitalized" -capitalized = "capitalized" -capitalising = "capitalizing" -capitalizing = "capitalizing" -capitaliser = "capitalizer" -capitalizer = "capitalizer" -capitalisers = "capitalizers" -capitalizers = "capitalizers" -capitalisable = "capitalizable" -capitalizable = "capitalizable" -capitalisation = "capitalization" -capitalization = "capitalization" -capitalisations = "capitalizations" -capitalizations = "capitalizations" -categorise = "categorize" -categorize = "categorize" -categorises = "categorizes" -categorizes = "categorizes" -categorised = "categorized" -categorized = "categorized" -categorising = "categorizing" -categorizing = "categorizing" -categoriser = "categorizer" -categorizer = "categorizer" -categorisers = "categorizers" -categorizers = "categorizers" -categorisable = "categorizable" -categorizable = "categorizable" -categorisation = "categorization" -categorization = "categorization" -categorisations = "categorizations" -categorizations = "categorizations" -centralise = "centralize" -centralize = "centralize" -centralises = "centralizes" -centralizes = "centralizes" -centralised = "centralized" -centralized = "centralized" -centralising = "centralizing" -centralizing = "centralizing" -centraliser = "centralizer" -centralizer = "centralizer" -centralisers = "centralizers" -centralizers = "centralizers" -centralisable = "centralizable" -centralizable = "centralizable" -centralisation = "centralization" -centralization = "centralization" -centralisations = "centralizations" -centralizations = "centralizations" -characterise = "characterize" -characterize = "characterize" -characterises = "characterizes" -characterizes = "characterizes" -characterised = "characterized" -characterized = "characterized" -characterising = "characterizing" -characterizing = "characterizing" -characteriser = "characterizer" -characterizer = "characterizer" -characterisers = "characterizers" -characterizers = "characterizers" -characterisable = "characterizable" -characterizable = "characterizable" -characterisation = "characterization" -characterization = "characterization" -characterisations = "characterizations" -characterizations = "characterizations" -civilise = "civilize" -civilize = "civilize" -civilises = "civilizes" -civilizes = "civilizes" -civilised = "civilized" -civilized = "civilized" -civilising = "civilizing" -civilizing = "civilizing" -civiliser = "civilizer" -civilizer = "civilizer" -civilisers = "civilizers" -civilizers = "civilizers" -civilisable = "civilizable" -civilizable = "civilizable" -civilisation = "civilization" -civilization = "civilization" -civilisations = "civilizations" -civilizations = "civilizations" -colonise = "colonize" -colonize = "colonize" -colonises = "colonizes" -colonizes = "colonizes" -colonised = "colonized" -colonized = "colonized" -colonising = "colonizing" -colonizing = "colonizing" -coloniser = "colonizer" -colonizer = "colonizer" -colonisers = "colonizers" -colonizers = "colonizers" -colonisable = "colonizable" -colonizable = "colonizable" -colonisation = "colonization" -colonization = "colonization" -colonisations = "colonizations" -colonizations = "colonizations" -conceptualise = "conceptualize" -conceptualize = "conceptualize" -conceptualises = "conceptualizes" -conceptualizes = "conceptualizes" -conceptualised = "conceptualized" -conceptualized = "conceptualized" -conceptualising = "conceptualizing" -conceptualizing = "conceptualizing" -conceptualiser = "conceptualizer" -conceptualizer = "conceptualizer" -conceptualisers = "conceptualizers" -conceptualizers = "conceptualizers" -conceptualisable = "conceptualizable" -conceptualizable = "conceptualizable" -conceptualisation = "conceptualization" -conceptualization = "conceptualization" -conceptualisations = "conceptualizations" -conceptualizations = "conceptualizations" -containerise = "containerize" -containerize = "containerize" -containerises = "containerizes" -containerizes = "containerizes" -containerised = "containerized" -containerized = "containerized" -containerising = "containerizing" -containerizing = "containerizing" -containeriser = "containerizer" -containerizer = "containerizer" -containerisers = "containerizers" -containerizers = "containerizers" -containerisable = "containerizable" -containerizable = "containerizable" -containerisation = "containerization" -containerization = "containerization" -containerisations = "containerizations" -containerizations = "containerizations" -contextualise = "contextualize" -contextualize = "contextualize" -contextualises = "contextualizes" -contextualizes = "contextualizes" -contextualised = "contextualized" -contextualized = "contextualized" -contextualising = "contextualizing" -contextualizing = "contextualizing" -contextualiser = "contextualizer" -contextualizer = "contextualizer" -contextualisers = "contextualizers" -contextualizers = "contextualizers" -contextualisable = "contextualizable" -contextualizable = "contextualizable" -contextualisation = "contextualization" -contextualization = "contextualization" -contextualisations = "contextualizations" -contextualizations = "contextualizations" -criticise = "criticize" -criticize = "criticize" -criticises = "criticizes" -criticizes = "criticizes" -criticised = "criticized" -criticized = "criticized" -criticising = "criticizing" -criticizing = "criticizing" -criticiser = "criticizer" -criticizer = "criticizer" -criticisers = "criticizers" -criticizers = "criticizers" -criticisable = "criticizable" -criticizable = "criticizable" -criticisation = "criticization" -criticization = "criticization" -criticisations = "criticizations" -criticizations = "criticizations" -customise = "customize" -customize = "customize" -customises = "customizes" -customizes = "customizes" -customised = "customized" -customized = "customized" -customising = "customizing" -customizing = "customizing" -customiser = "customizer" -customizer = "customizer" -customisers = "customizers" -customizers = "customizers" -customisable = "customizable" -customizable = "customizable" -customisation = "customization" -customization = "customization" -customisations = "customizations" -customizations = "customizations" -decentralise = "decentralize" -decentralize = "decentralize" -decentralises = "decentralizes" -decentralizes = "decentralizes" -decentralised = "decentralized" -decentralized = "decentralized" -decentralising = "decentralizing" -decentralizing = "decentralizing" -decentraliser = "decentralizer" -decentralizer = "decentralizer" -decentralisers = "decentralizers" -decentralizers = "decentralizers" -decentralisable = "decentralizable" -decentralizable = "decentralizable" -decentralisation = "decentralization" -decentralization = "decentralization" -decentralisations = "decentralizations" -decentralizations = "decentralizations" -denormalise = "denormalize" -denormalize = "denormalize" -denormalises = "denormalizes" -denormalizes = "denormalizes" -denormalised = "denormalized" -denormalized = "denormalized" -denormalising = "denormalizing" -denormalizing = "denormalizing" -denormaliser = "denormalizer" -denormalizer = "denormalizer" -denormalisers = "denormalizers" -denormalizers = "denormalizers" -denormalisable = "denormalizable" -denormalizable = "denormalizable" -denormalisation = "denormalization" -denormalization = "denormalization" -denormalisations = "denormalizations" -denormalizations = "denormalizations" -deserialise = "deserialize" -deserialize = "deserialize" -deserialises = "deserializes" -deserializes = "deserializes" -deserialised = "deserialized" -deserialized = "deserialized" -deserialising = "deserializing" -deserializing = "deserializing" -deserialiser = "deserializer" -deserializer = "deserializer" -deserialisers = "deserializers" -deserializers = "deserializers" -deserialisable = "deserializable" -deserializable = "deserializable" -deserialisation = "deserialization" -deserialization = "deserialization" -deserialisations = "deserializations" -deserializations = "deserializations" -destabilise = "destabilize" -destabilize = "destabilize" -destabilises = "destabilizes" -destabilizes = "destabilizes" -destabilised = "destabilized" -destabilized = "destabilized" -destabilising = "destabilizing" -destabilizing = "destabilizing" -destabiliser = "destabilizer" -destabilizer = "destabilizer" -destabilisers = "destabilizers" -destabilizers = "destabilizers" -destabilisable = "destabilizable" -destabilizable = "destabilizable" -destabilisation = "destabilization" -destabilization = "destabilization" -destabilisations = "destabilizations" -destabilizations = "destabilizations" -emphasise = "emphasize" -emphasize = "emphasize" -emphasises = "emphasizes" -emphasizes = "emphasizes" -emphasised = "emphasized" -emphasized = "emphasized" -emphasising = "emphasizing" -emphasizing = "emphasizing" -emphasiser = "emphasizer" -emphasizer = "emphasizer" -emphasisers = "emphasizers" -emphasizers = "emphasizers" -emphasisable = "emphasizable" -emphasizable = "emphasizable" -emphasisation = "emphasization" -emphasization = "emphasization" -emphasisations = "emphasizations" -emphasizations = "emphasizations" -energise = "energize" -energize = "energize" -energises = "energizes" -energizes = "energizes" -energised = "energized" -energized = "energized" -energising = "energizing" -energizing = "energizing" -energiser = "energizer" -energizer = "energizer" -energisers = "energizers" -energizers = "energizers" -energisable = "energizable" -energizable = "energizable" -energisation = "energization" -energization = "energization" -energisations = "energizations" -energizations = "energizations" -equalise = "equalize" -equalize = "equalize" -equalises = "equalizes" -equalizes = "equalizes" -equalised = "equalized" -equalized = "equalized" -equalising = "equalizing" -equalizing = "equalizing" -equaliser = "equalizer" -equalizer = "equalizer" -equalisers = "equalizers" -equalizers = "equalizers" -equalisable = "equalizable" -equalizable = "equalizable" -equalisation = "equalization" -equalization = "equalization" -equalisations = "equalizations" -equalizations = "equalizations" -externalise = "externalize" -externalize = "externalize" -externalises = "externalizes" -externalizes = "externalizes" -externalised = "externalized" -externalized = "externalized" -externalising = "externalizing" -externalizing = "externalizing" -externaliser = "externalizer" -externalizer = "externalizer" -externalisers = "externalizers" -externalizers = "externalizers" -externalisable = "externalizable" -externalizable = "externalizable" -externalisation = "externalization" -externalization = "externalization" -externalisations = "externalizations" -externalizations = "externalizations" -factorise = "factorize" -factorize = "factorize" -factorises = "factorizes" -factorizes = "factorizes" -factorised = "factorized" -factorized = "factorized" -factorising = "factorizing" -factorizing = "factorizing" -factoriser = "factorizer" -factorizer = "factorizer" -factorisers = "factorizers" -factorizers = "factorizers" -factorisable = "factorizable" -factorizable = "factorizable" -factorisation = "factorization" -factorization = "factorization" -factorisations = "factorizations" -factorizations = "factorizations" -familiarise = "familiarize" -familiarize = "familiarize" -familiarises = "familiarizes" -familiarizes = "familiarizes" -familiarised = "familiarized" -familiarized = "familiarized" -familiarising = "familiarizing" -familiarizing = "familiarizing" -familiariser = "familiarizer" -familiarizer = "familiarizer" -familiarisers = "familiarizers" -familiarizers = "familiarizers" -familiarisable = "familiarizable" -familiarizable = "familiarizable" -familiarisation = "familiarization" -familiarization = "familiarization" -familiarisations = "familiarizations" -familiarizations = "familiarizations" -finalise = "finalize" -finalize = "finalize" -finalises = "finalizes" -finalizes = "finalizes" -finalised = "finalized" -finalized = "finalized" -finalising = "finalizing" -finalizing = "finalizing" -finaliser = "finalizer" -finalizer = "finalizer" -finalisers = "finalizers" -finalizers = "finalizers" -finalisable = "finalizable" -finalizable = "finalizable" -finalisation = "finalization" -finalization = "finalization" -finalisations = "finalizations" -finalizations = "finalizations" -formalise = "formalize" -formalize = "formalize" -formalises = "formalizes" -formalizes = "formalizes" -formalised = "formalized" -formalized = "formalized" -formalising = "formalizing" -formalizing = "formalizing" -formaliser = "formalizer" -formalizer = "formalizer" -formalisers = "formalizers" -formalizers = "formalizers" -formalisable = "formalizable" -formalizable = "formalizable" -formalisation = "formalization" -formalization = "formalization" -formalisations = "formalizations" -formalizations = "formalizations" -fossilise = "fossilize" -fossilize = "fossilize" -fossilises = "fossilizes" -fossilizes = "fossilizes" -fossilised = "fossilized" -fossilized = "fossilized" -fossilising = "fossilizing" -fossilizing = "fossilizing" -fossiliser = "fossilizer" -fossilizer = "fossilizer" -fossilisers = "fossilizers" -fossilizers = "fossilizers" -fossilisable = "fossilizable" -fossilizable = "fossilizable" -fossilisation = "fossilization" -fossilization = "fossilization" -fossilisations = "fossilizations" -fossilizations = "fossilizations" -generalise = "generalize" -generalize = "generalize" -generalises = "generalizes" -generalizes = "generalizes" -generalised = "generalized" -generalized = "generalized" -generalising = "generalizing" -generalizing = "generalizing" -generaliser = "generalizer" -generalizer = "generalizer" -generalisers = "generalizers" -generalizers = "generalizers" -generalisable = "generalizable" -generalizable = "generalizable" -generalisation = "generalization" -generalization = "generalization" -generalisations = "generalizations" -generalizations = "generalizations" -globalise = "globalize" -globalize = "globalize" -globalises = "globalizes" -globalizes = "globalizes" -globalised = "globalized" -globalized = "globalized" -globalising = "globalizing" -globalizing = "globalizing" -globaliser = "globalizer" -globalizer = "globalizer" -globalisers = "globalizers" -globalizers = "globalizers" -globalisable = "globalizable" -globalizable = "globalizable" -globalisation = "globalization" -globalization = "globalization" -globalisations = "globalizations" -globalizations = "globalizations" -harmonise = "harmonize" -harmonize = "harmonize" -harmonises = "harmonizes" -harmonizes = "harmonizes" -harmonised = "harmonized" -harmonized = "harmonized" -harmonising = "harmonizing" -harmonizing = "harmonizing" -harmoniser = "harmonizer" -harmonizer = "harmonizer" -harmonisers = "harmonizers" -harmonizers = "harmonizers" -harmonisable = "harmonizable" -harmonizable = "harmonizable" -harmonisation = "harmonization" -harmonization = "harmonization" -harmonisations = "harmonizations" -harmonizations = "harmonizations" -hypothesise = "hypothesize" -hypothesize = "hypothesize" -hypothesises = "hypothesizes" -hypothesizes = "hypothesizes" -hypothesised = "hypothesized" -hypothesized = "hypothesized" -hypothesising = "hypothesizing" -hypothesizing = "hypothesizing" -hypothesiser = "hypothesizer" -hypothesizer = "hypothesizer" -hypothesisers = "hypothesizers" -hypothesizers = "hypothesizers" -hypothesisable = "hypothesizable" -hypothesizable = "hypothesizable" -hypothesisation = "hypothesization" -hypothesization = "hypothesization" -hypothesisations = "hypothesizations" -hypothesizations = "hypothesizations" -idealise = "idealize" -idealize = "idealize" -idealises = "idealizes" -idealizes = "idealizes" -idealised = "idealized" -idealized = "idealized" -idealising = "idealizing" -idealizing = "idealizing" -idealiser = "idealizer" -idealizer = "idealizer" -idealisers = "idealizers" -idealizers = "idealizers" -idealisable = "idealizable" -idealizable = "idealizable" -idealisation = "idealization" -idealization = "idealization" -idealisations = "idealizations" -idealizations = "idealizations" -initialise = "initialize" -initialize = "initialize" -initialises = "initializes" -initializes = "initializes" -initialised = "initialized" -initialized = "initialized" -initialising = "initializing" -initializing = "initializing" -initialiser = "initializer" -initializer = "initializer" -initialisers = "initializers" -initializers = "initializers" -initialisable = "initializable" -initializable = "initializable" -initialisation = "initialization" -initialization = "initialization" -initialisations = "initializations" -initializations = "initializations" -internalise = "internalize" -internalize = "internalize" -internalises = "internalizes" -internalizes = "internalizes" -internalised = "internalized" -internalized = "internalized" -internalising = "internalizing" -internalizing = "internalizing" -internaliser = "internalizer" -internalizer = "internalizer" -internalisers = "internalizers" -internalizers = "internalizers" -internalisable = "internalizable" -internalizable = "internalizable" -internalisation = "internalization" -internalization = "internalization" -internalisations = "internalizations" -internalizations = "internalizations" -internationalise = "internationalize" -internationalize = "internationalize" -internationalises = "internationalizes" -internationalizes = "internationalizes" -internationalised = "internationalized" -internationalized = "internationalized" -internationalising = "internationalizing" -internationalizing = "internationalizing" -internationaliser = "internationalizer" -internationalizer = "internationalizer" -internationalisers = "internationalizers" -internationalizers = "internationalizers" -internationalisable = "internationalizable" -internationalizable = "internationalizable" -internationalisation = "internationalization" -internationalization = "internationalization" -internationalisations = "internationalizations" -internationalizations = "internationalizations" -itemise = "itemize" -itemize = "itemize" -itemises = "itemizes" -itemizes = "itemizes" -itemised = "itemized" -itemized = "itemized" -itemising = "itemizing" -itemizing = "itemizing" -itemiser = "itemizer" -itemizer = "itemizer" -itemisers = "itemizers" -itemizers = "itemizers" -itemisable = "itemizable" -itemizable = "itemizable" -itemisation = "itemization" -itemization = "itemization" -itemisations = "itemizations" -itemizations = "itemizations" -jeopardise = "jeopardize" -jeopardize = "jeopardize" -jeopardises = "jeopardizes" -jeopardizes = "jeopardizes" -jeopardised = "jeopardized" -jeopardized = "jeopardized" -jeopardising = "jeopardizing" -jeopardizing = "jeopardizing" -jeopardiser = "jeopardizer" -jeopardizer = "jeopardizer" -jeopardisers = "jeopardizers" -jeopardizers = "jeopardizers" -jeopardisable = "jeopardizable" -jeopardizable = "jeopardizable" -jeopardisation = "jeopardization" -jeopardization = "jeopardization" -jeopardisations = "jeopardizations" -jeopardizations = "jeopardizations" -legitimise = "legitimize" -legitimize = "legitimize" -legitimises = "legitimizes" -legitimizes = "legitimizes" -legitimised = "legitimized" -legitimized = "legitimized" -legitimising = "legitimizing" -legitimizing = "legitimizing" -legitimiser = "legitimizer" -legitimizer = "legitimizer" -legitimisers = "legitimizers" -legitimizers = "legitimizers" -legitimisable = "legitimizable" -legitimizable = "legitimizable" -legitimisation = "legitimization" -legitimization = "legitimization" -legitimisations = "legitimizations" -legitimizations = "legitimizations" -linearise = "linearize" -linearize = "linearize" -linearises = "linearizes" -linearizes = "linearizes" -linearised = "linearized" -linearized = "linearized" -linearising = "linearizing" -linearizing = "linearizing" -lineariser = "linearizer" -linearizer = "linearizer" -linearisers = "linearizers" -linearizers = "linearizers" -linearisable = "linearizable" -linearizable = "linearizable" -linearisation = "linearization" -linearization = "linearization" -linearisations = "linearizations" -linearizations = "linearizations" -localise = "localize" -localize = "localize" -localises = "localizes" -localizes = "localizes" -localised = "localized" -localized = "localized" -localising = "localizing" -localizing = "localizing" -localiser = "localizer" -localizer = "localizer" -localisers = "localizers" -localizers = "localizers" -localisable = "localizable" -localizable = "localizable" -localisation = "localization" -localization = "localization" -localisations = "localizations" -localizations = "localizations" -materialise = "materialize" -materialize = "materialize" -materialises = "materializes" -materializes = "materializes" -materialised = "materialized" -materialized = "materialized" -materialising = "materializing" -materializing = "materializing" -materialiser = "materializer" -materializer = "materializer" -materialisers = "materializers" -materializers = "materializers" -materialisable = "materializable" -materializable = "materializable" -materialisation = "materialization" -materialization = "materialization" -materialisations = "materializations" -materializations = "materializations" -maximise = "maximize" -maximize = "maximize" -maximises = "maximizes" -maximizes = "maximizes" -maximised = "maximized" -maximized = "maximized" -maximising = "maximizing" -maximizing = "maximizing" -maximiser = "maximizer" -maximizer = "maximizer" -maximisers = "maximizers" -maximizers = "maximizers" -maximisable = "maximizable" -maximizable = "maximizable" -maximisation = "maximization" -maximization = "maximization" -maximisations = "maximizations" -maximizations = "maximizations" -mechanise = "mechanize" -mechanize = "mechanize" -mechanises = "mechanizes" -mechanizes = "mechanizes" -mechanised = "mechanized" -mechanized = "mechanized" -mechanising = "mechanizing" -mechanizing = "mechanizing" -mechaniser = "mechanizer" -mechanizer = "mechanizer" -mechanisers = "mechanizers" -mechanizers = "mechanizers" -mechanisable = "mechanizable" -mechanizable = "mechanizable" -mechanisation = "mechanization" -mechanization = "mechanization" -mechanisations = "mechanizations" -mechanizations = "mechanizations" -memorise = "memorize" -memorize = "memorize" -memorises = "memorizes" -memorizes = "memorizes" -memorised = "memorized" -memorized = "memorized" -memorising = "memorizing" -memorizing = "memorizing" -memoriser = "memorizer" -memorizer = "memorizer" -memorisers = "memorizers" -memorizers = "memorizers" -memorisable = "memorizable" -memorizable = "memorizable" -memorisation = "memorization" -memorization = "memorization" -memorisations = "memorizations" -memorizations = "memorizations" -miniaturise = "miniaturize" -miniaturize = "miniaturize" -miniaturises = "miniaturizes" -miniaturizes = "miniaturizes" -miniaturised = "miniaturized" -miniaturized = "miniaturized" -miniaturising = "miniaturizing" -miniaturizing = "miniaturizing" -miniaturiser = "miniaturizer" -miniaturizer = "miniaturizer" -miniaturisers = "miniaturizers" -miniaturizers = "miniaturizers" -miniaturisable = "miniaturizable" -miniaturizable = "miniaturizable" -miniaturisation = "miniaturization" -miniaturization = "miniaturization" -miniaturisations = "miniaturizations" -miniaturizations = "miniaturizations" -minimise = "minimize" -minimize = "minimize" -minimises = "minimizes" -minimizes = "minimizes" -minimised = "minimized" -minimized = "minimized" -minimising = "minimizing" -minimizing = "minimizing" -minimiser = "minimizer" -minimizer = "minimizer" -minimisers = "minimizers" -minimizers = "minimizers" -minimisable = "minimizable" -minimizable = "minimizable" -minimisation = "minimization" -minimization = "minimization" -minimisations = "minimizations" -minimizations = "minimizations" -mobilise = "mobilize" -mobilize = "mobilize" -mobilises = "mobilizes" -mobilizes = "mobilizes" -mobilised = "mobilized" -mobilized = "mobilized" -mobilising = "mobilizing" -mobilizing = "mobilizing" -mobiliser = "mobilizer" -mobilizer = "mobilizer" -mobilisers = "mobilizers" -mobilizers = "mobilizers" -mobilisable = "mobilizable" -mobilizable = "mobilizable" -mobilisation = "mobilization" -mobilization = "mobilization" -mobilisations = "mobilizations" -mobilizations = "mobilizations" -modernise = "modernize" -modernize = "modernize" -modernises = "modernizes" -modernizes = "modernizes" -modernised = "modernized" -modernized = "modernized" -modernising = "modernizing" -modernizing = "modernizing" -moderniser = "modernizer" -modernizer = "modernizer" -modernisers = "modernizers" -modernizers = "modernizers" -modernisable = "modernizable" -modernizable = "modernizable" -modernisation = "modernization" -modernization = "modernization" -modernisations = "modernizations" -modernizations = "modernizations" -modularise = "modularize" -modularize = "modularize" -modularises = "modularizes" -modularizes = "modularizes" -modularised = "modularized" -modularized = "modularized" -modularising = "modularizing" -modularizing = "modularizing" -modulariser = "modularizer" -modularizer = "modularizer" -modularisers = "modularizers" -modularizers = "modularizers" -modularisable = "modularizable" -modularizable = "modularizable" -modularisation = "modularization" -modularization = "modularization" -modularisations = "modularizations" -modularizations = "modularizations" -monetise = "monetize" -monetize = "monetize" -monetises = "monetizes" -monetizes = "monetizes" -monetised = "monetized" -monetized = "monetized" -monetising = "monetizing" -monetizing = "monetizing" -monetiser = "monetizer" -monetizer = "monetizer" -monetisers = "monetizers" -monetizers = "monetizers" -monetisable = "monetizable" -monetizable = "monetizable" -monetisation = "monetization" -monetization = "monetization" -monetisations = "monetizations" -monetizations = "monetizations" -neutralise = "neutralize" -neutralize = "neutralize" -neutralises = "neutralizes" -neutralizes = "neutralizes" -neutralised = "neutralized" -neutralized = "neutralized" -neutralising = "neutralizing" -neutralizing = "neutralizing" -neutraliser = "neutralizer" -neutralizer = "neutralizer" -neutralisers = "neutralizers" -neutralizers = "neutralizers" -neutralisable = "neutralizable" -neutralizable = "neutralizable" -neutralisation = "neutralization" -neutralization = "neutralization" -neutralisations = "neutralizations" -neutralizations = "neutralizations" -normalise = "normalize" -normalize = "normalize" -normalises = "normalizes" -normalizes = "normalizes" -normalised = "normalized" -normalized = "normalized" -normalising = "normalizing" -normalizing = "normalizing" -normaliser = "normalizer" -normalizer = "normalizer" -normalisers = "normalizers" -normalizers = "normalizers" -normalisable = "normalizable" -normalizable = "normalizable" -normalisation = "normalization" -normalization = "normalization" -normalisations = "normalizations" -normalizations = "normalizations" -operationalise = "operationalize" -operationalize = "operationalize" -operationalises = "operationalizes" -operationalizes = "operationalizes" -operationalised = "operationalized" -operationalized = "operationalized" -operationalising = "operationalizing" -operationalizing = "operationalizing" -operationaliser = "operationalizer" -operationalizer = "operationalizer" -operationalisers = "operationalizers" -operationalizers = "operationalizers" -operationalisable = "operationalizable" -operationalizable = "operationalizable" -operationalisation = "operationalization" -operationalization = "operationalization" -operationalisations = "operationalizations" -operationalizations = "operationalizations" -optimise = "optimize" -optimize = "optimize" -optimises = "optimizes" -optimizes = "optimizes" -optimised = "optimized" -optimized = "optimized" -optimising = "optimizing" -optimizing = "optimizing" -optimiser = "optimizer" -optimizer = "optimizer" -optimisers = "optimizers" -optimizers = "optimizers" -optimisable = "optimizable" -optimizable = "optimizable" -optimisation = "optimization" -optimization = "optimization" -optimisations = "optimizations" -optimizations = "optimizations" -organise = "organize" -organize = "organize" -organises = "organizes" -organizes = "organizes" -organised = "organized" -organized = "organized" -organising = "organizing" -organizing = "organizing" -organiser = "organizer" -organizer = "organizer" -organisers = "organizers" -organizers = "organizers" -organisable = "organizable" -organizable = "organizable" -organisation = "organization" -organization = "organization" -organisations = "organizations" -organizations = "organizations" -parameterise = "parameterize" -parameterize = "parameterize" -parameterises = "parameterizes" -parameterizes = "parameterizes" -parameterised = "parameterized" -parameterized = "parameterized" -parameterising = "parameterizing" -parameterizing = "parameterizing" -parameteriser = "parameterizer" -parameterizer = "parameterizer" -parameterisers = "parameterizers" -parameterizers = "parameterizers" -parameterisable = "parameterizable" -parameterizable = "parameterizable" -parameterisation = "parameterization" -parameterization = "parameterization" -parameterisations = "parameterizations" -parameterizations = "parameterizations" -parametrise = "parametrize" -parametrize = "parametrize" -parametrises = "parametrizes" -parametrizes = "parametrizes" -parametrised = "parametrized" -parametrized = "parametrized" -parametrising = "parametrizing" -parametrizing = "parametrizing" -parametriser = "parametrizer" -parametrizer = "parametrizer" -parametrisers = "parametrizers" -parametrizers = "parametrizers" -parametrisable = "parametrizable" -parametrizable = "parametrizable" -parametrisation = "parametrization" -parametrization = "parametrization" -parametrisations = "parametrizations" -parametrizations = "parametrizations" -penalise = "penalize" -penalize = "penalize" -penalises = "penalizes" -penalizes = "penalizes" -penalised = "penalized" -penalized = "penalized" -penalising = "penalizing" -penalizing = "penalizing" -penaliser = "penalizer" -penalizer = "penalizer" -penalisers = "penalizers" -penalizers = "penalizers" -penalisable = "penalizable" -penalizable = "penalizable" -penalisation = "penalization" -penalization = "penalization" -penalisations = "penalizations" -penalizations = "penalizations" -personalise = "personalize" -personalize = "personalize" -personalises = "personalizes" -personalizes = "personalizes" -personalised = "personalized" -personalized = "personalized" -personalising = "personalizing" -personalizing = "personalizing" -personaliser = "personalizer" -personalizer = "personalizer" -personalisers = "personalizers" -personalizers = "personalizers" -personalisable = "personalizable" -personalizable = "personalizable" -personalisation = "personalization" -personalization = "personalization" -personalisations = "personalizations" -personalizations = "personalizations" -pluralise = "pluralize" -pluralize = "pluralize" -pluralises = "pluralizes" -pluralizes = "pluralizes" -pluralised = "pluralized" -pluralized = "pluralized" -pluralising = "pluralizing" -pluralizing = "pluralizing" -pluraliser = "pluralizer" -pluralizer = "pluralizer" -pluralisers = "pluralizers" -pluralizers = "pluralizers" -pluralisable = "pluralizable" -pluralizable = "pluralizable" -pluralisation = "pluralization" -pluralization = "pluralization" -pluralisations = "pluralizations" -pluralizations = "pluralizations" -prioritise = "prioritize" -prioritize = "prioritize" -prioritises = "prioritizes" -prioritizes = "prioritizes" -prioritised = "prioritized" -prioritized = "prioritized" -prioritising = "prioritizing" -prioritizing = "prioritizing" -prioritiser = "prioritizer" -prioritizer = "prioritizer" -prioritisers = "prioritizers" -prioritizers = "prioritizers" -prioritisable = "prioritizable" -prioritizable = "prioritizable" -prioritisation = "prioritization" -prioritization = "prioritization" -prioritisations = "prioritizations" -prioritizations = "prioritizations" -publicise = "publicize" -publicize = "publicize" -publicises = "publicizes" -publicizes = "publicizes" -publicised = "publicized" -publicized = "publicized" -publicising = "publicizing" -publicizing = "publicizing" -publiciser = "publicizer" -publicizer = "publicizer" -publicisers = "publicizers" -publicizers = "publicizers" -publicisable = "publicizable" -publicizable = "publicizable" -publicisation = "publicization" -publicization = "publicization" -publicisations = "publicizations" -publicizations = "publicizations" -quantise = "quantize" -quantize = "quantize" -quantises = "quantizes" -quantizes = "quantizes" -quantised = "quantized" -quantized = "quantized" -quantising = "quantizing" -quantizing = "quantizing" -quantiser = "quantizer" -quantizer = "quantizer" -quantisers = "quantizers" -quantizers = "quantizers" -quantisable = "quantizable" -quantizable = "quantizable" -quantisation = "quantization" -quantization = "quantization" -quantisations = "quantizations" -quantizations = "quantizations" -randomise = "randomize" -randomize = "randomize" -randomises = "randomizes" -randomizes = "randomizes" -randomised = "randomized" -randomized = "randomized" -randomising = "randomizing" -randomizing = "randomizing" -randomiser = "randomizer" -randomizer = "randomizer" -randomisers = "randomizers" -randomizers = "randomizers" -randomisable = "randomizable" -randomizable = "randomizable" -randomisation = "randomization" -randomization = "randomization" -randomisations = "randomizations" -randomizations = "randomizations" -rationalise = "rationalize" -rationalize = "rationalize" -rationalises = "rationalizes" -rationalizes = "rationalizes" -rationalised = "rationalized" -rationalized = "rationalized" -rationalising = "rationalizing" -rationalizing = "rationalizing" -rationaliser = "rationalizer" -rationalizer = "rationalizer" -rationalisers = "rationalizers" -rationalizers = "rationalizers" -rationalisable = "rationalizable" -rationalizable = "rationalizable" -rationalisation = "rationalization" -rationalization = "rationalization" -rationalisations = "rationalizations" -rationalizations = "rationalizations" -realise = "realize" -realize = "realize" -realises = "realizes" -realizes = "realizes" -realised = "realized" -realized = "realized" -realising = "realizing" -realizing = "realizing" -realiser = "realizer" -realizer = "realizer" -realisers = "realizers" -realizers = "realizers" -realisable = "realizable" -realizable = "realizable" -realisation = "realization" -realization = "realization" -realisations = "realizations" -realizations = "realizations" -recognise = "recognize" -recognize = "recognize" -recognises = "recognizes" -recognizes = "recognizes" -recognised = "recognized" -recognized = "recognized" -recognising = "recognizing" -recognizing = "recognizing" -recogniser = "recognizer" -recognizer = "recognizer" -recognisers = "recognizers" -recognizers = "recognizers" -recognisable = "recognizable" -recognizable = "recognizable" -recognisation = "recognization" -recognization = "recognization" -recognisations = "recognizations" -recognizations = "recognizations" -regularise = "regularize" -regularize = "regularize" -regularises = "regularizes" -regularizes = "regularizes" -regularised = "regularized" -regularized = "regularized" -regularising = "regularizing" -regularizing = "regularizing" -regulariser = "regularizer" -regularizer = "regularizer" -regularisers = "regularizers" -regularizers = "regularizers" -regularisable = "regularizable" -regularizable = "regularizable" -regularisation = "regularization" -regularization = "regularization" -regularisations = "regularizations" -regularizations = "regularizations" -reinitialise = "reinitialize" -reinitialize = "reinitialize" -reinitialises = "reinitializes" -reinitializes = "reinitializes" -reinitialised = "reinitialized" -reinitialized = "reinitialized" -reinitialising = "reinitializing" -reinitializing = "reinitializing" -reinitialiser = "reinitializer" -reinitializer = "reinitializer" -reinitialisers = "reinitializers" -reinitializers = "reinitializers" -reinitialisable = "reinitializable" -reinitializable = "reinitializable" -reinitialisation = "reinitialization" -reinitialization = "reinitialization" -reinitialisations = "reinitializations" -reinitializations = "reinitializations" -reorganise = "reorganize" -reorganize = "reorganize" -reorganises = "reorganizes" -reorganizes = "reorganizes" -reorganised = "reorganized" -reorganized = "reorganized" -reorganising = "reorganizing" -reorganizing = "reorganizing" -reorganiser = "reorganizer" -reorganizer = "reorganizer" -reorganisers = "reorganizers" -reorganizers = "reorganizers" -reorganisable = "reorganizable" -reorganizable = "reorganizable" -reorganisation = "reorganization" -reorganization = "reorganization" -reorganisations = "reorganizations" -reorganizations = "reorganizations" -sanitise = "sanitize" -sanitize = "sanitize" -sanitises = "sanitizes" -sanitizes = "sanitizes" -sanitised = "sanitized" -sanitized = "sanitized" -sanitising = "sanitizing" -sanitizing = "sanitizing" -sanitiser = "sanitizer" -sanitizer = "sanitizer" -sanitisers = "sanitizers" -sanitizers = "sanitizers" -sanitisable = "sanitizable" -sanitizable = "sanitizable" -sanitisation = "sanitization" -sanitization = "sanitization" -sanitisations = "sanitizations" -sanitizations = "sanitizations" -scrutinise = "scrutinize" -scrutinize = "scrutinize" -scrutinises = "scrutinizes" -scrutinizes = "scrutinizes" -scrutinised = "scrutinized" -scrutinized = "scrutinized" -scrutinising = "scrutinizing" -scrutinizing = "scrutinizing" -scrutiniser = "scrutinizer" -scrutinizer = "scrutinizer" -scrutinisers = "scrutinizers" -scrutinizers = "scrutinizers" -scrutinisable = "scrutinizable" -scrutinizable = "scrutinizable" -scrutinisation = "scrutinization" -scrutinization = "scrutinization" -scrutinisations = "scrutinizations" -scrutinizations = "scrutinizations" -serialise = "serialize" -serialize = "serialize" -serialises = "serializes" -serializes = "serializes" -serialised = "serialized" -serialized = "serialized" -serialising = "serializing" -serializing = "serializing" -serialiser = "serializer" -serializer = "serializer" -serialisers = "serializers" -serializers = "serializers" -serialisable = "serializable" -serializable = "serializable" -serialisation = "serialization" -serialization = "serialization" -serialisations = "serializations" -serializations = "serializations" -singularise = "singularize" -singularize = "singularize" -singularises = "singularizes" -singularizes = "singularizes" -singularised = "singularized" -singularized = "singularized" -singularising = "singularizing" -singularizing = "singularizing" -singulariser = "singularizer" -singularizer = "singularizer" -singularisers = "singularizers" -singularizers = "singularizers" -singularisable = "singularizable" -singularizable = "singularizable" -singularisation = "singularization" -singularization = "singularization" -singularisations = "singularizations" -singularizations = "singularizations" -specialise = "specialize" -specialize = "specialize" -specialises = "specializes" -specializes = "specializes" -specialised = "specialized" -specialized = "specialized" -specialising = "specializing" -specializing = "specializing" -specialiser = "specializer" -specializer = "specializer" -specialisers = "specializers" -specializers = "specializers" -specialisable = "specializable" -specializable = "specializable" -specialisation = "specialization" -specialization = "specialization" -specialisations = "specializations" -specializations = "specializations" -stabilise = "stabilize" -stabilize = "stabilize" -stabilises = "stabilizes" -stabilizes = "stabilizes" -stabilised = "stabilized" -stabilized = "stabilized" -stabilising = "stabilizing" -stabilizing = "stabilizing" -stabiliser = "stabilizer" -stabilizer = "stabilizer" -stabilisers = "stabilizers" -stabilizers = "stabilizers" -stabilisable = "stabilizable" -stabilizable = "stabilizable" -stabilisation = "stabilization" -stabilization = "stabilization" -stabilisations = "stabilizations" -stabilizations = "stabilizations" -standardise = "standardize" -standardize = "standardize" -standardises = "standardizes" -standardizes = "standardizes" -standardised = "standardized" -standardized = "standardized" -standardising = "standardizing" -standardizing = "standardizing" -standardiser = "standardizer" -standardizer = "standardizer" -standardisers = "standardizers" -standardizers = "standardizers" -standardisable = "standardizable" -standardizable = "standardizable" -standardisation = "standardization" -standardization = "standardization" -standardisations = "standardizations" -standardizations = "standardizations" -stylise = "stylize" -stylize = "stylize" -stylises = "stylizes" -stylizes = "stylizes" -stylised = "stylized" -stylized = "stylized" -stylising = "stylizing" -stylizing = "stylizing" -styliser = "stylizer" -stylizer = "stylizer" -stylisers = "stylizers" -stylizers = "stylizers" -stylisable = "stylizable" -stylizable = "stylizable" -stylisation = "stylization" -stylization = "stylization" -stylisations = "stylizations" -stylizations = "stylizations" -subsidise = "subsidize" -subsidize = "subsidize" -subsidises = "subsidizes" -subsidizes = "subsidizes" -subsidised = "subsidized" -subsidized = "subsidized" -subsidising = "subsidizing" -subsidizing = "subsidizing" -subsidiser = "subsidizer" -subsidizer = "subsidizer" -subsidisers = "subsidizers" -subsidizers = "subsidizers" -subsidisable = "subsidizable" -subsidizable = "subsidizable" -subsidisation = "subsidization" -subsidization = "subsidization" -subsidisations = "subsidizations" -subsidizations = "subsidizations" -summarise = "summarize" -summarize = "summarize" -summarises = "summarizes" -summarizes = "summarizes" -summarised = "summarized" -summarized = "summarized" -summarising = "summarizing" -summarizing = "summarizing" -summariser = "summarizer" -summarizer = "summarizer" -summarisers = "summarizers" -summarizers = "summarizers" -summarisable = "summarizable" -summarizable = "summarizable" -summarisation = "summarization" -summarization = "summarization" -summarisations = "summarizations" -summarizations = "summarizations" -symbolise = "symbolize" -symbolize = "symbolize" -symbolises = "symbolizes" -symbolizes = "symbolizes" -symbolised = "symbolized" -symbolized = "symbolized" -symbolising = "symbolizing" -symbolizing = "symbolizing" -symboliser = "symbolizer" -symbolizer = "symbolizer" -symbolisers = "symbolizers" -symbolizers = "symbolizers" -symbolisable = "symbolizable" -symbolizable = "symbolizable" -symbolisation = "symbolization" -symbolization = "symbolization" -symbolisations = "symbolizations" -symbolizations = "symbolizations" -synchronise = "synchronize" -synchronize = "synchronize" -synchronises = "synchronizes" -synchronizes = "synchronizes" -synchronised = "synchronized" -synchronized = "synchronized" -synchronising = "synchronizing" -synchronizing = "synchronizing" -synchroniser = "synchronizer" -synchronizer = "synchronizer" -synchronisers = "synchronizers" -synchronizers = "synchronizers" -synchronisable = "synchronizable" -synchronizable = "synchronizable" -synchronisation = "synchronization" -synchronization = "synchronization" -synchronisations = "synchronizations" -synchronizations = "synchronizations" -synthesise = "synthesize" -synthesize = "synthesize" -synthesises = "synthesizes" -synthesizes = "synthesizes" -synthesised = "synthesized" -synthesized = "synthesized" -synthesising = "synthesizing" -synthesizing = "synthesizing" -synthesiser = "synthesizer" -synthesizer = "synthesizer" -synthesisers = "synthesizers" -synthesizers = "synthesizers" -synthesisable = "synthesizable" -synthesizable = "synthesizable" -synthesisation = "synthesization" -synthesization = "synthesization" -synthesisations = "synthesizations" -synthesizations = "synthesizations" -systematise = "systematize" -systematize = "systematize" -systematises = "systematizes" -systematizes = "systematizes" -systematised = "systematized" -systematized = "systematized" -systematising = "systematizing" -systematizing = "systematizing" -systematiser = "systematizer" -systematizer = "systematizer" -systematisers = "systematizers" -systematizers = "systematizers" -systematisable = "systematizable" -systematizable = "systematizable" -systematisation = "systematization" -systematization = "systematization" -systematisations = "systematizations" -systematizations = "systematizations" -theorise = "theorize" -theorize = "theorize" -theorises = "theorizes" -theorizes = "theorizes" -theorised = "theorized" -theorized = "theorized" -theorising = "theorizing" -theorizing = "theorizing" -theoriser = "theorizer" -theorizer = "theorizer" -theorisers = "theorizers" -theorizers = "theorizers" -theorisable = "theorizable" -theorizable = "theorizable" -theorisation = "theorization" -theorization = "theorization" -theorisations = "theorizations" -theorizations = "theorizations" -tokenise = "tokenize" -tokenize = "tokenize" -tokenises = "tokenizes" -tokenizes = "tokenizes" -tokenised = "tokenized" -tokenized = "tokenized" -tokenising = "tokenizing" -tokenizing = "tokenizing" -tokeniser = "tokenizer" -tokenizer = "tokenizer" -tokenisers = "tokenizers" -tokenizers = "tokenizers" -tokenisable = "tokenizable" -tokenizable = "tokenizable" -tokenisation = "tokenization" -tokenization = "tokenization" -tokenisations = "tokenizations" -tokenizations = "tokenizations" -trivialise = "trivialize" -trivialize = "trivialize" -trivialises = "trivializes" -trivializes = "trivializes" -trivialised = "trivialized" -trivialized = "trivialized" -trivialising = "trivializing" -trivializing = "trivializing" -trivialiser = "trivializer" -trivializer = "trivializer" -trivialisers = "trivializers" -trivializers = "trivializers" -trivialisable = "trivializable" -trivializable = "trivializable" -trivialisation = "trivialization" -trivialization = "trivialization" -trivialisations = "trivializations" -trivializations = "trivializations" -unauthorise = "unauthorize" -unauthorize = "unauthorize" -unauthorises = "unauthorizes" -unauthorizes = "unauthorizes" -unauthorised = "unauthorized" -unauthorized = "unauthorized" -unauthorising = "unauthorizing" -unauthorizing = "unauthorizing" -unauthoriser = "unauthorizer" -unauthorizer = "unauthorizer" -unauthorisers = "unauthorizers" -unauthorizers = "unauthorizers" -unauthorisable = "unauthorizable" -unauthorizable = "unauthorizable" -unauthorisation = "unauthorization" -unauthorization = "unauthorization" -unauthorisations = "unauthorizations" -unauthorizations = "unauthorizations" -uncategorise = "uncategorize" -uncategorize = "uncategorize" -uncategorises = "uncategorizes" -uncategorizes = "uncategorizes" -uncategorised = "uncategorized" -uncategorized = "uncategorized" -uncategorising = "uncategorizing" -uncategorizing = "uncategorizing" -uncategoriser = "uncategorizer" -uncategorizer = "uncategorizer" -uncategorisers = "uncategorizers" -uncategorizers = "uncategorizers" -uncategorisable = "uncategorizable" -uncategorizable = "uncategorizable" -uncategorisation = "uncategorization" -uncategorization = "uncategorization" -uncategorisations = "uncategorizations" -uncategorizations = "uncategorizations" -uninitialise = "uninitialize" -uninitialize = "uninitialize" -uninitialises = "uninitializes" -uninitializes = "uninitializes" -uninitialised = "uninitialized" -uninitialized = "uninitialized" -uninitialising = "uninitializing" -uninitializing = "uninitializing" -uninitialiser = "uninitializer" -uninitializer = "uninitializer" -uninitialisers = "uninitializers" -uninitializers = "uninitializers" -uninitialisable = "uninitializable" -uninitializable = "uninitializable" -uninitialisation = "uninitialization" -uninitialization = "uninitialization" -uninitialisations = "uninitializations" -uninitializations = "uninitializations" -unoptimise = "unoptimize" -unoptimize = "unoptimize" -unoptimises = "unoptimizes" -unoptimizes = "unoptimizes" -unoptimised = "unoptimized" -unoptimized = "unoptimized" -unoptimising = "unoptimizing" -unoptimizing = "unoptimizing" -unoptimiser = "unoptimizer" -unoptimizer = "unoptimizer" -unoptimisers = "unoptimizers" -unoptimizers = "unoptimizers" -unoptimisable = "unoptimizable" -unoptimizable = "unoptimizable" -unoptimisation = "unoptimization" -unoptimization = "unoptimization" -unoptimisations = "unoptimizations" -unoptimizations = "unoptimizations" -unrecognise = "unrecognize" -unrecognize = "unrecognize" -unrecognises = "unrecognizes" -unrecognizes = "unrecognizes" -unrecognised = "unrecognized" -unrecognized = "unrecognized" -unrecognising = "unrecognizing" -unrecognizing = "unrecognizing" -unrecogniser = "unrecognizer" -unrecognizer = "unrecognizer" -unrecognisers = "unrecognizers" -unrecognizers = "unrecognizers" -unrecognisable = "unrecognizable" -unrecognizable = "unrecognizable" -unrecognisation = "unrecognization" -unrecognization = "unrecognization" -unrecognisations = "unrecognizations" -unrecognizations = "unrecognizations" -unsynchronise = "unsynchronize" -unsynchronize = "unsynchronize" -unsynchronises = "unsynchronizes" -unsynchronizes = "unsynchronizes" -unsynchronised = "unsynchronized" -unsynchronized = "unsynchronized" -unsynchronising = "unsynchronizing" -unsynchronizing = "unsynchronizing" -unsynchroniser = "unsynchronizer" -unsynchronizer = "unsynchronizer" -unsynchronisers = "unsynchronizers" -unsynchronizers = "unsynchronizers" -unsynchronisable = "unsynchronizable" -unsynchronizable = "unsynchronizable" -unsynchronisation = "unsynchronization" -unsynchronization = "unsynchronization" -unsynchronisations = "unsynchronizations" -unsynchronizations = "unsynchronizations" -utilise = "utilize" -utilize = "utilize" -utilises = "utilizes" -utilizes = "utilizes" -utilised = "utilized" -utilized = "utilized" -utilising = "utilizing" -utilizing = "utilizing" -utiliser = "utilizer" -utilizer = "utilizer" -utilisers = "utilizers" -utilizers = "utilizers" -utilisable = "utilizable" -utilizable = "utilizable" -utilisation = "utilization" -utilization = "utilization" -utilisations = "utilizations" -utilizations = "utilizations" -vectorise = "vectorize" -vectorize = "vectorize" -vectorises = "vectorizes" -vectorizes = "vectorizes" -vectorised = "vectorized" -vectorized = "vectorized" -vectorising = "vectorizing" -vectorizing = "vectorizing" -vectoriser = "vectorizer" -vectorizer = "vectorizer" -vectorisers = "vectorizers" -vectorizers = "vectorizers" -vectorisable = "vectorizable" -vectorizable = "vectorizable" -vectorisation = "vectorization" -vectorization = "vectorization" -vectorisations = "vectorizations" -vectorizations = "vectorizations" -virtualise = "virtualize" -virtualize = "virtualize" -virtualises = "virtualizes" -virtualizes = "virtualizes" -virtualised = "virtualized" -virtualized = "virtualized" -virtualising = "virtualizing" -virtualizing = "virtualizing" -virtualiser = "virtualizer" -virtualizer = "virtualizer" -virtualisers = "virtualizers" -virtualizers = "virtualizers" -virtualisable = "virtualizable" -virtualizable = "virtualizable" -virtualisation = "virtualization" -virtualization = "virtualization" -virtualisations = "virtualizations" -virtualizations = "virtualizations" -visualise = "visualize" -visualize = "visualize" -visualises = "visualizes" -visualizes = "visualizes" -visualised = "visualized" -visualized = "visualized" -visualising = "visualizing" -visualizing = "visualizing" -visualiser = "visualizer" -visualizer = "visualizer" -visualisers = "visualizers" -visualizers = "visualizers" -visualisable = "visualizable" -visualizable = "visualizable" -visualisation = "visualization" -visualization = "visualization" -visualisations = "visualizations" -visualizations = "visualizations" +"ASO" = "ASO" +"absolutisable" = "absolutizable" +"absolutisably" = "absolutizably" +"absolutisation" = "absolutization" +"absolutisations" = "absolutizations" +"absolutise" = "absolutize" +"absolutised" = "absolutized" +"absolutiser" = "absolutizer" +"absolutisers" = "absolutizers" +"absolutises" = "absolutizes" +"absolutising" = "absolutizing" +"absolutizable" = "absolutizable" +"absolutizably" = "absolutizably" +"absolutization" = "absolutization" +"absolutizations" = "absolutizations" +"absolutize" = "absolutize" +"absolutized" = "absolutized" +"absolutizer" = "absolutizer" +"absolutizers" = "absolutizers" +"absolutizes" = "absolutizes" +"absolutizing" = "absolutizing" +"actualisable" = "actualizable" +"actualisably" = "actualizably" +"actualisation" = "actualization" +"actualisations" = "actualizations" +"actualise" = "actualize" +"actualised" = "actualized" +"actualiser" = "actualizer" +"actualisers" = "actualizers" +"actualises" = "actualizes" +"actualising" = "actualizing" +"actualizable" = "actualizable" +"actualizably" = "actualizably" +"actualization" = "actualization" +"actualizations" = "actualizations" +"actualize" = "actualize" +"actualized" = "actualized" +"actualizer" = "actualizer" +"actualizers" = "actualizers" +"actualizes" = "actualizes" +"actualizing" = "actualizing" +"alphabetisable" = "alphabetizable" +"alphabetisably" = "alphabetizably" +"alphabetisation" = "alphabetization" +"alphabetisations" = "alphabetizations" +"alphabetise" = "alphabetize" +"alphabetised" = "alphabetized" +"alphabetiser" = "alphabetizer" +"alphabetisers" = "alphabetizers" +"alphabetises" = "alphabetizes" +"alphabetising" = "alphabetizing" +"alphabetizable" = "alphabetizable" +"alphabetizably" = "alphabetizably" +"alphabetization" = "alphabetization" +"alphabetizations" = "alphabetizations" +"alphabetize" = "alphabetize" +"alphabetized" = "alphabetized" +"alphabetizer" = "alphabetizer" +"alphabetizers" = "alphabetizers" +"alphabetizes" = "alphabetizes" +"alphabetizing" = "alphabetizing" +"amortisable" = "amortizable" +"amortisably" = "amortizably" +"amortisation" = "amortization" +"amortisations" = "amortizations" +"amortise" = "amortize" +"amortised" = "amortized" +"amortiser" = "amortizer" +"amortisers" = "amortizers" +"amortises" = "amortizes" +"amortising" = "amortizing" +"amortizable" = "amortizable" +"amortizably" = "amortizably" +"amortization" = "amortization" +"amortizations" = "amortizations" +"amortize" = "amortize" +"amortized" = "amortized" +"amortizer" = "amortizer" +"amortizers" = "amortizers" +"amortizes" = "amortizes" +"amortizing" = "amortizing" +"anonymisable" = "anonymizable" +"anonymisably" = "anonymizably" +"anonymisation" = "anonymization" +"anonymisations" = "anonymizations" +"anonymise" = "anonymize" +"anonymised" = "anonymized" +"anonymiser" = "anonymizer" +"anonymisers" = "anonymizers" +"anonymises" = "anonymizes" +"anonymising" = "anonymizing" +"anonymizable" = "anonymizable" +"anonymizably" = "anonymizably" +"anonymization" = "anonymization" +"anonymizations" = "anonymizations" +"anonymize" = "anonymize" +"anonymized" = "anonymized" +"anonymizer" = "anonymizer" +"anonymizers" = "anonymizers" +"anonymizes" = "anonymizes" +"anonymizing" = "anonymizing" +"apologisable" = "apologizable" +"apologisably" = "apologizably" +"apologisation" = "apologization" +"apologisations" = "apologizations" +"apologise" = "apologize" +"apologised" = "apologized" +"apologiser" = "apologizer" +"apologisers" = "apologizers" +"apologises" = "apologizes" +"apologising" = "apologizing" +"apologizable" = "apologizable" +"apologizably" = "apologizably" +"apologization" = "apologization" +"apologizations" = "apologizations" +"apologize" = "apologize" +"apologized" = "apologized" +"apologizer" = "apologizer" +"apologizers" = "apologizers" +"apologizes" = "apologizes" +"apologizing" = "apologizing" +"artifact" = "artifact" +"artifacts" = "artifacts" +"atomisable" = "atomizable" +"atomisably" = "atomizably" +"atomisation" = "atomization" +"atomisations" = "atomizations" +"atomise" = "atomize" +"atomised" = "atomized" +"atomiser" = "atomizer" +"atomisers" = "atomizers" +"atomises" = "atomizes" +"atomising" = "atomizing" +"atomizable" = "atomizable" +"atomizably" = "atomizably" +"atomization" = "atomization" +"atomizations" = "atomizations" +"atomize" = "atomize" +"atomized" = "atomized" +"atomizer" = "atomizer" +"atomizers" = "atomizers" +"atomizes" = "atomizes" +"atomizing" = "atomizing" +"authorisable" = "authorizable" +"authorisably" = "authorizably" +"authorisation" = "authorization" +"authorisations" = "authorizations" +"authorise" = "authorize" +"authorised" = "authorized" +"authoriser" = "authorizer" +"authorisers" = "authorizers" +"authorises" = "authorizes" +"authorising" = "authorizing" +"authorizable" = "authorizable" +"authorizably" = "authorizably" +"authorization" = "authorization" +"authorizations" = "authorizations" +"authorize" = "authorize" +"authorized" = "authorized" +"authorizer" = "authorizer" +"authorizers" = "authorizers" +"authorizes" = "authorizes" +"authorizing" = "authorizing" +"canonicalisable" = "canonicalizable" +"canonicalisably" = "canonicalizably" +"canonicalisation" = "canonicalization" +"canonicalisations" = "canonicalizations" +"canonicalise" = "canonicalize" +"canonicalised" = "canonicalized" +"canonicaliser" = "canonicalizer" +"canonicalisers" = "canonicalizers" +"canonicalises" = "canonicalizes" +"canonicalising" = "canonicalizing" +"canonicalizable" = "canonicalizable" +"canonicalizably" = "canonicalizably" +"canonicalization" = "canonicalization" +"canonicalizations" = "canonicalizations" +"canonicalize" = "canonicalize" +"canonicalized" = "canonicalized" +"canonicalizer" = "canonicalizer" +"canonicalizers" = "canonicalizers" +"canonicalizes" = "canonicalizes" +"canonicalizing" = "canonicalizing" +"capitalisable" = "capitalizable" +"capitalisably" = "capitalizably" +"capitalisation" = "capitalization" +"capitalisations" = "capitalizations" +"capitalise" = "capitalize" +"capitalised" = "capitalized" +"capitaliser" = "capitalizer" +"capitalisers" = "capitalizers" +"capitalises" = "capitalizes" +"capitalising" = "capitalizing" +"capitalizable" = "capitalizable" +"capitalizably" = "capitalizably" +"capitalization" = "capitalization" +"capitalizations" = "capitalizations" +"capitalize" = "capitalize" +"capitalized" = "capitalized" +"capitalizer" = "capitalizer" +"capitalizers" = "capitalizers" +"capitalizes" = "capitalizes" +"capitalizing" = "capitalizing" +"categorisable" = "categorizable" +"categorisably" = "categorizably" +"categorisation" = "categorization" +"categorisations" = "categorizations" +"categorise" = "categorize" +"categorised" = "categorized" +"categoriser" = "categorizer" +"categorisers" = "categorizers" +"categorises" = "categorizes" +"categorising" = "categorizing" +"categorizable" = "categorizable" +"categorizably" = "categorizably" +"categorization" = "categorization" +"categorizations" = "categorizations" +"categorize" = "categorize" +"categorized" = "categorized" +"categorizer" = "categorizer" +"categorizers" = "categorizers" +"categorizes" = "categorizes" +"categorizing" = "categorizing" +"centralisable" = "centralizable" +"centralisably" = "centralizably" +"centralisation" = "centralization" +"centralisations" = "centralizations" +"centralise" = "centralize" +"centralised" = "centralized" +"centraliser" = "centralizer" +"centralisers" = "centralizers" +"centralises" = "centralizes" +"centralising" = "centralizing" +"centralizable" = "centralizable" +"centralizably" = "centralizably" +"centralization" = "centralization" +"centralizations" = "centralizations" +"centralize" = "centralize" +"centralized" = "centralized" +"centralizer" = "centralizer" +"centralizers" = "centralizers" +"centralizes" = "centralizes" +"centralizing" = "centralizing" +"characterisable" = "characterizable" +"characterisably" = "characterizably" +"characterisation" = "characterization" +"characterisations" = "characterizations" +"characterise" = "characterize" +"characterised" = "characterized" +"characteriser" = "characterizer" +"characterisers" = "characterizers" +"characterises" = "characterizes" +"characterising" = "characterizing" +"characterizable" = "characterizable" +"characterizably" = "characterizably" +"characterization" = "characterization" +"characterizations" = "characterizations" +"characterize" = "characterize" +"characterized" = "characterized" +"characterizer" = "characterizer" +"characterizers" = "characterizers" +"characterizes" = "characterizes" +"characterizing" = "characterizing" +"civilisable" = "civilizable" +"civilisably" = "civilizably" +"civilisation" = "civilization" +"civilisations" = "civilizations" +"civilise" = "civilize" +"civilised" = "civilized" +"civiliser" = "civilizer" +"civilisers" = "civilizers" +"civilises" = "civilizes" +"civilising" = "civilizing" +"civilizable" = "civilizable" +"civilizably" = "civilizably" +"civilization" = "civilization" +"civilizations" = "civilizations" +"civilize" = "civilize" +"civilized" = "civilized" +"civilizer" = "civilizer" +"civilizers" = "civilizers" +"civilizes" = "civilizes" +"civilizing" = "civilizing" +"colonisable" = "colonizable" +"colonisably" = "colonizably" +"colonisation" = "colonization" +"colonisations" = "colonizations" +"colonise" = "colonize" +"colonised" = "colonized" +"coloniser" = "colonizer" +"colonisers" = "colonizers" +"colonises" = "colonizes" +"colonising" = "colonizing" +"colonizable" = "colonizable" +"colonizably" = "colonizably" +"colonization" = "colonization" +"colonizations" = "colonizations" +"colonize" = "colonize" +"colonized" = "colonized" +"colonizer" = "colonizer" +"colonizers" = "colonizers" +"colonizes" = "colonizes" +"colonizing" = "colonizing" +"colourisable" = "colourizable" +"colourisably" = "colourizably" +"colourisation" = "colourization" +"colourisations" = "colourizations" +"colourise" = "colourize" +"colourised" = "colourized" +"colouriser" = "colourizer" +"colourisers" = "colourizers" +"colourises" = "colourizes" +"colourising" = "colourizing" +"colourizable" = "colourizable" +"colourizably" = "colourizably" +"colourization" = "colourization" +"colourizations" = "colourizations" +"colourize" = "colourize" +"colourized" = "colourized" +"colourizer" = "colourizer" +"colourizers" = "colourizers" +"colourizes" = "colourizes" +"colourizing" = "colourizing" +"commonisable" = "commonizable" +"commonisably" = "commonizably" +"commonisation" = "commonization" +"commonisations" = "commonizations" +"commonise" = "commonize" +"commonised" = "commonized" +"commoniser" = "commonizer" +"commonisers" = "commonizers" +"commonises" = "commonizes" +"commonising" = "commonizing" +"commonizable" = "commonizable" +"commonizably" = "commonizably" +"commonization" = "commonization" +"commonizations" = "commonizations" +"commonize" = "commonize" +"commonized" = "commonized" +"commonizer" = "commonizer" +"commonizers" = "commonizers" +"commonizes" = "commonizes" +"commonizing" = "commonizing" +"conceptualisable" = "conceptualizable" +"conceptualisably" = "conceptualizably" +"conceptualisation" = "conceptualization" +"conceptualisations" = "conceptualizations" +"conceptualise" = "conceptualize" +"conceptualised" = "conceptualized" +"conceptualiser" = "conceptualizer" +"conceptualisers" = "conceptualizers" +"conceptualises" = "conceptualizes" +"conceptualising" = "conceptualizing" +"conceptualizable" = "conceptualizable" +"conceptualizably" = "conceptualizably" +"conceptualization" = "conceptualization" +"conceptualizations" = "conceptualizations" +"conceptualize" = "conceptualize" +"conceptualized" = "conceptualized" +"conceptualizer" = "conceptualizer" +"conceptualizers" = "conceptualizers" +"conceptualizes" = "conceptualizes" +"conceptualizing" = "conceptualizing" +"concretisable" = "concretizable" +"concretisably" = "concretizably" +"concretisation" = "concretization" +"concretisations" = "concretizations" +"concretise" = "concretize" +"concretised" = "concretized" +"concretiser" = "concretizer" +"concretisers" = "concretizers" +"concretises" = "concretizes" +"concretising" = "concretizing" +"concretizable" = "concretizable" +"concretizably" = "concretizably" +"concretization" = "concretization" +"concretizations" = "concretizations" +"concretize" = "concretize" +"concretized" = "concretized" +"concretizer" = "concretizer" +"concretizers" = "concretizers" +"concretizes" = "concretizes" +"concretizing" = "concretizing" +"containerisable" = "containerizable" +"containerisably" = "containerizably" +"containerisation" = "containerization" +"containerisations" = "containerizations" +"containerise" = "containerize" +"containerised" = "containerized" +"containeriser" = "containerizer" +"containerisers" = "containerizers" +"containerises" = "containerizes" +"containerising" = "containerizing" +"containerizable" = "containerizable" +"containerizably" = "containerizably" +"containerization" = "containerization" +"containerizations" = "containerizations" +"containerize" = "containerize" +"containerized" = "containerized" +"containerizer" = "containerizer" +"containerizers" = "containerizers" +"containerizes" = "containerizes" +"containerizing" = "containerizing" +"contextualisable" = "contextualizable" +"contextualisably" = "contextualizably" +"contextualisation" = "contextualization" +"contextualisations" = "contextualizations" +"contextualise" = "contextualize" +"contextualised" = "contextualized" +"contextualiser" = "contextualizer" +"contextualisers" = "contextualizers" +"contextualises" = "contextualizes" +"contextualising" = "contextualizing" +"contextualizable" = "contextualizable" +"contextualizably" = "contextualizably" +"contextualization" = "contextualization" +"contextualizations" = "contextualizations" +"contextualize" = "contextualize" +"contextualized" = "contextualized" +"contextualizer" = "contextualizer" +"contextualizers" = "contextualizers" +"contextualizes" = "contextualizes" +"contextualizing" = "contextualizing" +"criticisable" = "criticizable" +"criticisably" = "criticizably" +"criticisation" = "criticization" +"criticisations" = "criticizations" +"criticise" = "criticize" +"criticised" = "criticized" +"criticiser" = "criticizer" +"criticisers" = "criticizers" +"criticises" = "criticizes" +"criticising" = "criticizing" +"criticizable" = "criticizable" +"criticizably" = "criticizably" +"criticization" = "criticization" +"criticizations" = "criticizations" +"criticize" = "criticize" +"criticized" = "criticized" +"criticizer" = "criticizer" +"criticizers" = "criticizers" +"criticizes" = "criticizes" +"criticizing" = "criticizing" +"crystallisable" = "crystallizable" +"crystallisably" = "crystallizably" +"crystallisation" = "crystallization" +"crystallisations" = "crystallizations" +"crystallise" = "crystallize" +"crystallised" = "crystallized" +"crystalliser" = "crystallizer" +"crystallisers" = "crystallizers" +"crystallises" = "crystallizes" +"crystallising" = "crystallizing" +"crystallizable" = "crystallizable" +"crystallizably" = "crystallizably" +"crystallization" = "crystallization" +"crystallizations" = "crystallizations" +"crystallize" = "crystallize" +"crystallized" = "crystallized" +"crystallizer" = "crystallizer" +"crystallizers" = "crystallizers" +"crystallizes" = "crystallizes" +"crystallizing" = "crystallizing" +"customisable" = "customizable" +"customisably" = "customizably" +"customisation" = "customization" +"customisations" = "customizations" +"customise" = "customize" +"customised" = "customized" +"customiser" = "customizer" +"customisers" = "customizers" +"customises" = "customizes" +"customising" = "customizing" +"customizable" = "customizable" +"customizably" = "customizably" +"customization" = "customization" +"customizations" = "customizations" +"customize" = "customize" +"customized" = "customized" +"customizer" = "customizer" +"customizers" = "customizers" +"customizes" = "customizes" +"customizing" = "customizing" +"daemonisable" = "daemonizable" +"daemonisably" = "daemonizably" +"daemonisation" = "daemonization" +"daemonisations" = "daemonizations" +"daemonise" = "daemonize" +"daemonised" = "daemonized" +"daemoniser" = "daemonizer" +"daemonisers" = "daemonizers" +"daemonises" = "daemonizes" +"daemonising" = "daemonizing" +"daemonizable" = "daemonizable" +"daemonizably" = "daemonizably" +"daemonization" = "daemonization" +"daemonizations" = "daemonizations" +"daemonize" = "daemonize" +"daemonized" = "daemonized" +"daemonizer" = "daemonizer" +"daemonizers" = "daemonizers" +"daemonizes" = "daemonizes" +"daemonizing" = "daemonizing" +"decentralisable" = "decentralizable" +"decentralisably" = "decentralizably" +"decentralisation" = "decentralization" +"decentralisations" = "decentralizations" +"decentralise" = "decentralize" +"decentralised" = "decentralized" +"decentraliser" = "decentralizer" +"decentralisers" = "decentralizers" +"decentralises" = "decentralizes" +"decentralising" = "decentralizing" +"decentralizable" = "decentralizable" +"decentralizably" = "decentralizably" +"decentralization" = "decentralization" +"decentralizations" = "decentralizations" +"decentralize" = "decentralize" +"decentralized" = "decentralized" +"decentralizer" = "decentralizer" +"decentralizers" = "decentralizers" +"decentralizes" = "decentralizes" +"decentralizing" = "decentralizing" +"denormalisable" = "denormalizable" +"denormalisably" = "denormalizably" +"denormalisation" = "denormalization" +"denormalisations" = "denormalizations" +"denormalise" = "denormalize" +"denormalised" = "denormalized" +"denormaliser" = "denormalizer" +"denormalisers" = "denormalizers" +"denormalises" = "denormalizes" +"denormalising" = "denormalizing" +"denormalizable" = "denormalizable" +"denormalizably" = "denormalizably" +"denormalization" = "denormalization" +"denormalizations" = "denormalizations" +"denormalize" = "denormalize" +"denormalized" = "denormalized" +"denormalizer" = "denormalizer" +"denormalizers" = "denormalizers" +"denormalizes" = "denormalizes" +"denormalizing" = "denormalizing" +"derandomisable" = "derandomizable" +"derandomisably" = "derandomizably" +"derandomisation" = "derandomization" +"derandomisations" = "derandomizations" +"derandomise" = "derandomize" +"derandomised" = "derandomized" +"derandomiser" = "derandomizer" +"derandomisers" = "derandomizers" +"derandomises" = "derandomizes" +"derandomising" = "derandomizing" +"derandomizable" = "derandomizable" +"derandomizably" = "derandomizably" +"derandomization" = "derandomization" +"derandomizations" = "derandomizations" +"derandomize" = "derandomize" +"derandomized" = "derandomized" +"derandomizer" = "derandomizer" +"derandomizers" = "derandomizers" +"derandomizes" = "derandomizes" +"derandomizing" = "derandomizing" +"deserialisable" = "deserializable" +"deserialisably" = "deserializably" +"deserialisation" = "deserialization" +"deserialisations" = "deserializations" +"deserialise" = "deserialize" +"deserialised" = "deserialized" +"deserialiser" = "deserializer" +"deserialisers" = "deserializers" +"deserialises" = "deserializes" +"deserialising" = "deserializing" +"deserializable" = "deserializable" +"deserializably" = "deserializably" +"deserialization" = "deserialization" +"deserializations" = "deserializations" +"deserialize" = "deserialize" +"deserialized" = "deserialized" +"deserializer" = "deserializer" +"deserializers" = "deserializers" +"deserializes" = "deserializes" +"deserializing" = "deserializing" +"destabilisable" = "destabilizable" +"destabilisably" = "destabilizably" +"destabilisation" = "destabilization" +"destabilisations" = "destabilizations" +"destabilise" = "destabilize" +"destabilised" = "destabilized" +"destabiliser" = "destabilizer" +"destabilisers" = "destabilizers" +"destabilises" = "destabilizes" +"destabilising" = "destabilizing" +"destabilizable" = "destabilizable" +"destabilizably" = "destabilizably" +"destabilization" = "destabilization" +"destabilizations" = "destabilizations" +"destabilize" = "destabilize" +"destabilized" = "destabilized" +"destabilizer" = "destabilizer" +"destabilizers" = "destabilizers" +"destabilizes" = "destabilizes" +"destabilizing" = "destabilizing" +"desynchronisable" = "desynchronizable" +"desynchronisably" = "desynchronizably" +"desynchronisation" = "desynchronization" +"desynchronisations" = "desynchronizations" +"desynchronise" = "desynchronize" +"desynchronised" = "desynchronized" +"desynchroniser" = "desynchronizer" +"desynchronisers" = "desynchronizers" +"desynchronises" = "desynchronizes" +"desynchronising" = "desynchronizing" +"desynchronizable" = "desynchronizable" +"desynchronizably" = "desynchronizably" +"desynchronization" = "desynchronization" +"desynchronizations" = "desynchronizations" +"desynchronize" = "desynchronize" +"desynchronized" = "desynchronized" +"desynchronizer" = "desynchronizer" +"desynchronizers" = "desynchronizers" +"desynchronizes" = "desynchronizes" +"desynchronizing" = "desynchronizing" +"dialog" = "dialog" +"dialogs" = "dialogs" +"dockerisable" = "dockerizable" +"dockerisably" = "dockerizably" +"dockerisation" = "dockerization" +"dockerisations" = "dockerizations" +"dockerise" = "dockerize" +"dockerised" = "dockerized" +"dockeriser" = "dockerizer" +"dockerisers" = "dockerizers" +"dockerises" = "dockerizes" +"dockerising" = "dockerizing" +"dockerizable" = "dockerizable" +"dockerizably" = "dockerizably" +"dockerization" = "dockerization" +"dockerizations" = "dockerizations" +"dockerize" = "dockerize" +"dockerized" = "dockerized" +"dockerizer" = "dockerizer" +"dockerizers" = "dockerizers" +"dockerizes" = "dockerizes" +"dockerizing" = "dockerizing" +"dramatisable" = "dramatizable" +"dramatisably" = "dramatizably" +"dramatisation" = "dramatization" +"dramatisations" = "dramatizations" +"dramatise" = "dramatize" +"dramatised" = "dramatized" +"dramatiser" = "dramatizer" +"dramatisers" = "dramatizers" +"dramatises" = "dramatizes" +"dramatising" = "dramatizing" +"dramatizable" = "dramatizable" +"dramatizably" = "dramatizably" +"dramatization" = "dramatization" +"dramatizations" = "dramatizations" +"dramatize" = "dramatize" +"dramatized" = "dramatized" +"dramatizer" = "dramatizer" +"dramatizers" = "dramatizers" +"dramatizes" = "dramatizes" +"dramatizing" = "dramatizing" +"emphasisable" = "emphasizable" +"emphasisably" = "emphasizably" +"emphasisation" = "emphasization" +"emphasisations" = "emphasizations" +"emphasise" = "emphasize" +"emphasised" = "emphasized" +"emphasiser" = "emphasizer" +"emphasisers" = "emphasizers" +"emphasises" = "emphasizes" +"emphasising" = "emphasizing" +"emphasizable" = "emphasizable" +"emphasizably" = "emphasizably" +"emphasization" = "emphasization" +"emphasizations" = "emphasizations" +"emphasize" = "emphasize" +"emphasized" = "emphasized" +"emphasizer" = "emphasizer" +"emphasizers" = "emphasizers" +"emphasizes" = "emphasizes" +"emphasizing" = "emphasizing" +"energisable" = "energizable" +"energisably" = "energizably" +"energisation" = "energization" +"energisations" = "energizations" +"energise" = "energize" +"energised" = "energized" +"energiser" = "energizer" +"energisers" = "energizers" +"energises" = "energizes" +"energising" = "energizing" +"energizable" = "energizable" +"energizably" = "energizably" +"energization" = "energization" +"energizations" = "energizations" +"energize" = "energize" +"energized" = "energized" +"energizer" = "energizer" +"energizers" = "energizers" +"energizes" = "energizes" +"energizing" = "energizing" +"equalisable" = "equalizable" +"equalisably" = "equalizably" +"equalisation" = "equalization" +"equalisations" = "equalizations" +"equalise" = "equalize" +"equalised" = "equalized" +"equaliser" = "equalizer" +"equalisers" = "equalizers" +"equalises" = "equalizes" +"equalising" = "equalizing" +"equalizable" = "equalizable" +"equalizably" = "equalizably" +"equalization" = "equalization" +"equalizations" = "equalizations" +"equalize" = "equalize" +"equalized" = "equalized" +"equalizer" = "equalizer" +"equalizers" = "equalizers" +"equalizes" = "equalizes" +"equalizing" = "equalizing" +"externalisable" = "externalizable" +"externalisably" = "externalizably" +"externalisation" = "externalization" +"externalisations" = "externalizations" +"externalise" = "externalize" +"externalised" = "externalized" +"externaliser" = "externalizer" +"externalisers" = "externalizers" +"externalises" = "externalizes" +"externalising" = "externalizing" +"externalizable" = "externalizable" +"externalizably" = "externalizably" +"externalization" = "externalization" +"externalizations" = "externalizations" +"externalize" = "externalize" +"externalized" = "externalized" +"externalizer" = "externalizer" +"externalizers" = "externalizers" +"externalizes" = "externalizes" +"externalizing" = "externalizing" +"factorisable" = "factorizable" +"factorisably" = "factorizably" +"factorisation" = "factorization" +"factorisations" = "factorizations" +"factorise" = "factorize" +"factorised" = "factorized" +"factoriser" = "factorizer" +"factorisers" = "factorizers" +"factorises" = "factorizes" +"factorising" = "factorizing" +"factorizable" = "factorizable" +"factorizably" = "factorizably" +"factorization" = "factorization" +"factorizations" = "factorizations" +"factorize" = "factorize" +"factorized" = "factorized" +"factorizer" = "factorizer" +"factorizers" = "factorizers" +"factorizes" = "factorizes" +"factorizing" = "factorizing" +"familiarisable" = "familiarizable" +"familiarisably" = "familiarizably" +"familiarisation" = "familiarization" +"familiarisations" = "familiarizations" +"familiarise" = "familiarize" +"familiarised" = "familiarized" +"familiariser" = "familiarizer" +"familiarisers" = "familiarizers" +"familiarises" = "familiarizes" +"familiarising" = "familiarizing" +"familiarizable" = "familiarizable" +"familiarizably" = "familiarizably" +"familiarization" = "familiarization" +"familiarizations" = "familiarizations" +"familiarize" = "familiarize" +"familiarized" = "familiarized" +"familiarizer" = "familiarizer" +"familiarizers" = "familiarizers" +"familiarizes" = "familiarizes" +"familiarizing" = "familiarizing" +"finalisable" = "finalizable" +"finalisably" = "finalizably" +"finalisation" = "finalization" +"finalisations" = "finalizations" +"finalise" = "finalize" +"finalised" = "finalized" +"finaliser" = "finalizer" +"finalisers" = "finalizers" +"finalises" = "finalizes" +"finalising" = "finalizing" +"finalizable" = "finalizable" +"finalizably" = "finalizably" +"finalization" = "finalization" +"finalizations" = "finalizations" +"finalize" = "finalize" +"finalized" = "finalized" +"finalizer" = "finalizer" +"finalizers" = "finalizers" +"finalizes" = "finalizes" +"finalizing" = "finalizing" +"formalisable" = "formalizable" +"formalisably" = "formalizably" +"formalisation" = "formalization" +"formalisations" = "formalizations" +"formalise" = "formalize" +"formalised" = "formalized" +"formaliser" = "formalizer" +"formalisers" = "formalizers" +"formalises" = "formalizes" +"formalising" = "formalizing" +"formalizable" = "formalizable" +"formalizably" = "formalizably" +"formalization" = "formalization" +"formalizations" = "formalizations" +"formalize" = "formalize" +"formalized" = "formalized" +"formalizer" = "formalizer" +"formalizers" = "formalizers" +"formalizes" = "formalizes" +"formalizing" = "formalizing" +"fossilisable" = "fossilizable" +"fossilisably" = "fossilizably" +"fossilisation" = "fossilization" +"fossilisations" = "fossilizations" +"fossilise" = "fossilize" +"fossilised" = "fossilized" +"fossiliser" = "fossilizer" +"fossilisers" = "fossilizers" +"fossilises" = "fossilizes" +"fossilising" = "fossilizing" +"fossilizable" = "fossilizable" +"fossilizably" = "fossilizably" +"fossilization" = "fossilization" +"fossilizations" = "fossilizations" +"fossilize" = "fossilize" +"fossilized" = "fossilized" +"fossilizer" = "fossilizer" +"fossilizers" = "fossilizers" +"fossilizes" = "fossilizes" +"fossilizing" = "fossilizing" +"generalisable" = "generalizable" +"generalisably" = "generalizably" +"generalisation" = "generalization" +"generalisations" = "generalizations" +"generalise" = "generalize" +"generalised" = "generalized" +"generaliser" = "generalizer" +"generalisers" = "generalizers" +"generalises" = "generalizes" +"generalising" = "generalizing" +"generalizable" = "generalizable" +"generalizably" = "generalizably" +"generalization" = "generalization" +"generalizations" = "generalizations" +"generalize" = "generalize" +"generalized" = "generalized" +"generalizer" = "generalizer" +"generalizers" = "generalizers" +"generalizes" = "generalizes" +"generalizing" = "generalizing" +"globalisable" = "globalizable" +"globalisably" = "globalizably" +"globalisation" = "globalization" +"globalisations" = "globalizations" +"globalise" = "globalize" +"globalised" = "globalized" +"globaliser" = "globalizer" +"globalisers" = "globalizers" +"globalises" = "globalizes" +"globalising" = "globalizing" +"globalizable" = "globalizable" +"globalizably" = "globalizably" +"globalization" = "globalization" +"globalizations" = "globalizations" +"globalize" = "globalize" +"globalized" = "globalized" +"globalizer" = "globalizer" +"globalizers" = "globalizers" +"globalizes" = "globalizes" +"globalizing" = "globalizing" +"harmonisable" = "harmonizable" +"harmonisably" = "harmonizably" +"harmonisation" = "harmonization" +"harmonisations" = "harmonizations" +"harmonise" = "harmonize" +"harmonised" = "harmonized" +"harmoniser" = "harmonizer" +"harmonisers" = "harmonizers" +"harmonises" = "harmonizes" +"harmonising" = "harmonizing" +"harmonizable" = "harmonizable" +"harmonizably" = "harmonizably" +"harmonization" = "harmonization" +"harmonizations" = "harmonizations" +"harmonize" = "harmonize" +"harmonized" = "harmonized" +"harmonizer" = "harmonizer" +"harmonizers" = "harmonizers" +"harmonizes" = "harmonizes" +"harmonizing" = "harmonizing" +"homogenisable" = "homogenizable" +"homogenisably" = "homogenizably" +"homogenisation" = "homogenization" +"homogenisations" = "homogenizations" +"homogenise" = "homogenize" +"homogenised" = "homogenized" +"homogeniser" = "homogenizer" +"homogenisers" = "homogenizers" +"homogenises" = "homogenizes" +"homogenising" = "homogenizing" +"homogenizable" = "homogenizable" +"homogenizably" = "homogenizably" +"homogenization" = "homogenization" +"homogenizations" = "homogenizations" +"homogenize" = "homogenize" +"homogenized" = "homogenized" +"homogenizer" = "homogenizer" +"homogenizers" = "homogenizers" +"homogenizes" = "homogenizes" +"homogenizing" = "homogenizing" +"humanisable" = "humanizable" +"humanisably" = "humanizably" +"humanisation" = "humanization" +"humanisations" = "humanizations" +"humanise" = "humanize" +"humanised" = "humanized" +"humaniser" = "humanizer" +"humanisers" = "humanizers" +"humanises" = "humanizes" +"humanising" = "humanizing" +"humanizable" = "humanizable" +"humanizably" = "humanizably" +"humanization" = "humanization" +"humanizations" = "humanizations" +"humanize" = "humanize" +"humanized" = "humanized" +"humanizer" = "humanizer" +"humanizers" = "humanizers" +"humanizes" = "humanizes" +"humanizing" = "humanizing" +"hypothesisable" = "hypothesizable" +"hypothesisably" = "hypothesizably" +"hypothesisation" = "hypothesization" +"hypothesisations" = "hypothesizations" +"hypothesise" = "hypothesize" +"hypothesised" = "hypothesized" +"hypothesiser" = "hypothesizer" +"hypothesisers" = "hypothesizers" +"hypothesises" = "hypothesizes" +"hypothesising" = "hypothesizing" +"hypothesizable" = "hypothesizable" +"hypothesizably" = "hypothesizably" +"hypothesization" = "hypothesization" +"hypothesizations" = "hypothesizations" +"hypothesize" = "hypothesize" +"hypothesized" = "hypothesized" +"hypothesizer" = "hypothesizer" +"hypothesizers" = "hypothesizers" +"hypothesizes" = "hypothesizes" +"hypothesizing" = "hypothesizing" +"idealisable" = "idealizable" +"idealisably" = "idealizably" +"idealisation" = "idealization" +"idealisations" = "idealizations" +"idealise" = "idealize" +"idealised" = "idealized" +"idealiser" = "idealizer" +"idealisers" = "idealizers" +"idealises" = "idealizes" +"idealising" = "idealizing" +"idealizable" = "idealizable" +"idealizably" = "idealizably" +"idealization" = "idealization" +"idealizations" = "idealizations" +"idealize" = "idealize" +"idealized" = "idealized" +"idealizer" = "idealizer" +"idealizers" = "idealizers" +"idealizes" = "idealizes" +"idealizing" = "idealizing" +"industrialisable" = "industrializable" +"industrialisably" = "industrializably" +"industrialisation" = "industrialization" +"industrialisations" = "industrializations" +"industrialise" = "industrialize" +"industrialised" = "industrialized" +"industrialiser" = "industrializer" +"industrialisers" = "industrializers" +"industrialises" = "industrializes" +"industrialising" = "industrializing" +"industrializable" = "industrializable" +"industrializably" = "industrializably" +"industrialization" = "industrialization" +"industrializations" = "industrializations" +"industrialize" = "industrialize" +"industrialized" = "industrialized" +"industrializer" = "industrializer" +"industrializers" = "industrializers" +"industrializes" = "industrializes" +"industrializing" = "industrializing" +"initialisable" = "initializable" +"initialisably" = "initializably" +"initialisation" = "initialization" +"initialisations" = "initializations" +"initialise" = "initialize" +"initialised" = "initialized" +"initialiser" = "initializer" +"initialisers" = "initializers" +"initialises" = "initializes" +"initialising" = "initializing" +"initializable" = "initializable" +"initializably" = "initializably" +"initialization" = "initialization" +"initializations" = "initializations" +"initialize" = "initialize" +"initialized" = "initialized" +"initializer" = "initializer" +"initializers" = "initializers" +"initializes" = "initializes" +"initializing" = "initializing" +"institutionalisable" = "institutionalizable" +"institutionalisably" = "institutionalizably" +"institutionalisation" = "institutionalization" +"institutionalisations" = "institutionalizations" +"institutionalise" = "institutionalize" +"institutionalised" = "institutionalized" +"institutionaliser" = "institutionalizer" +"institutionalisers" = "institutionalizers" +"institutionalises" = "institutionalizes" +"institutionalising" = "institutionalizing" +"institutionalizable" = "institutionalizable" +"institutionalizably" = "institutionalizably" +"institutionalization" = "institutionalization" +"institutionalizations" = "institutionalizations" +"institutionalize" = "institutionalize" +"institutionalized" = "institutionalized" +"institutionalizer" = "institutionalizer" +"institutionalizers" = "institutionalizers" +"institutionalizes" = "institutionalizes" +"institutionalizing" = "institutionalizing" +"internalisable" = "internalizable" +"internalisably" = "internalizably" +"internalisation" = "internalization" +"internalisations" = "internalizations" +"internalise" = "internalize" +"internalised" = "internalized" +"internaliser" = "internalizer" +"internalisers" = "internalizers" +"internalises" = "internalizes" +"internalising" = "internalizing" +"internalizable" = "internalizable" +"internalizably" = "internalizably" +"internalization" = "internalization" +"internalizations" = "internalizations" +"internalize" = "internalize" +"internalized" = "internalized" +"internalizer" = "internalizer" +"internalizers" = "internalizers" +"internalizes" = "internalizes" +"internalizing" = "internalizing" +"internationalisable" = "internationalizable" +"internationalisably" = "internationalizably" +"internationalisation" = "internationalization" +"internationalisations" = "internationalizations" +"internationalise" = "internationalize" +"internationalised" = "internationalized" +"internationaliser" = "internationalizer" +"internationalisers" = "internationalizers" +"internationalises" = "internationalizes" +"internationalising" = "internationalizing" +"internationalizable" = "internationalizable" +"internationalizably" = "internationalizably" +"internationalization" = "internationalization" +"internationalizations" = "internationalizations" +"internationalize" = "internationalize" +"internationalized" = "internationalized" +"internationalizer" = "internationalizer" +"internationalizers" = "internationalizers" +"internationalizes" = "internationalizes" +"internationalizing" = "internationalizing" +"italicisable" = "italicizable" +"italicisably" = "italicizably" +"italicisation" = "italicization" +"italicisations" = "italicizations" +"italicise" = "italicize" +"italicised" = "italicized" +"italiciser" = "italicizer" +"italicisers" = "italicizers" +"italicises" = "italicizes" +"italicising" = "italicizing" +"italicizable" = "italicizable" +"italicizably" = "italicizably" +"italicization" = "italicization" +"italicizations" = "italicizations" +"italicize" = "italicize" +"italicized" = "italicized" +"italicizer" = "italicizer" +"italicizers" = "italicizers" +"italicizes" = "italicizes" +"italicizing" = "italicizing" +"itemisable" = "itemizable" +"itemisably" = "itemizably" +"itemisation" = "itemization" +"itemisations" = "itemizations" +"itemise" = "itemize" +"itemised" = "itemized" +"itemiser" = "itemizer" +"itemisers" = "itemizers" +"itemises" = "itemizes" +"itemising" = "itemizing" +"itemizable" = "itemizable" +"itemizably" = "itemizably" +"itemization" = "itemization" +"itemizations" = "itemizations" +"itemize" = "itemize" +"itemized" = "itemized" +"itemizer" = "itemizer" +"itemizers" = "itemizers" +"itemizes" = "itemizes" +"itemizing" = "itemizing" +"jeopardisable" = "jeopardizable" +"jeopardisably" = "jeopardizably" +"jeopardisation" = "jeopardization" +"jeopardisations" = "jeopardizations" +"jeopardise" = "jeopardize" +"jeopardised" = "jeopardized" +"jeopardiser" = "jeopardizer" +"jeopardisers" = "jeopardizers" +"jeopardises" = "jeopardizes" +"jeopardising" = "jeopardizing" +"jeopardizable" = "jeopardizable" +"jeopardizably" = "jeopardizably" +"jeopardization" = "jeopardization" +"jeopardizations" = "jeopardizations" +"jeopardize" = "jeopardize" +"jeopardized" = "jeopardized" +"jeopardizer" = "jeopardizer" +"jeopardizers" = "jeopardizers" +"jeopardizes" = "jeopardizes" +"jeopardizing" = "jeopardizing" +"kernelisable" = "kernelizable" +"kernelisably" = "kernelizably" +"kernelisation" = "kernelization" +"kernelisations" = "kernelizations" +"kernelise" = "kernelize" +"kernelised" = "kernelized" +"kerneliser" = "kernelizer" +"kernelisers" = "kernelizers" +"kernelises" = "kernelizes" +"kernelising" = "kernelizing" +"kernelizable" = "kernelizable" +"kernelizably" = "kernelizably" +"kernelization" = "kernelization" +"kernelizations" = "kernelizations" +"kernelize" = "kernelize" +"kernelized" = "kernelized" +"kernelizer" = "kernelizer" +"kernelizers" = "kernelizers" +"kernelizes" = "kernelizes" +"kernelizing" = "kernelizing" +"legitimisable" = "legitimizable" +"legitimisably" = "legitimizably" +"legitimisation" = "legitimization" +"legitimisations" = "legitimizations" +"legitimise" = "legitimize" +"legitimised" = "legitimized" +"legitimiser" = "legitimizer" +"legitimisers" = "legitimizers" +"legitimises" = "legitimizes" +"legitimising" = "legitimizing" +"legitimizable" = "legitimizable" +"legitimizably" = "legitimizably" +"legitimization" = "legitimization" +"legitimizations" = "legitimizations" +"legitimize" = "legitimize" +"legitimized" = "legitimized" +"legitimizer" = "legitimizer" +"legitimizers" = "legitimizers" +"legitimizes" = "legitimizes" +"legitimizing" = "legitimizing" +"lemmatisable" = "lemmatizable" +"lemmatisably" = "lemmatizably" +"lemmatisation" = "lemmatization" +"lemmatisations" = "lemmatizations" +"lemmatise" = "lemmatize" +"lemmatised" = "lemmatized" +"lemmatiser" = "lemmatizer" +"lemmatisers" = "lemmatizers" +"lemmatises" = "lemmatizes" +"lemmatising" = "lemmatizing" +"lemmatizable" = "lemmatizable" +"lemmatizably" = "lemmatizably" +"lemmatization" = "lemmatization" +"lemmatizations" = "lemmatizations" +"lemmatize" = "lemmatize" +"lemmatized" = "lemmatized" +"lemmatizer" = "lemmatizer" +"lemmatizers" = "lemmatizers" +"lemmatizes" = "lemmatizes" +"lemmatizing" = "lemmatizing" +"linearisable" = "linearizable" +"linearisably" = "linearizably" +"linearisation" = "linearization" +"linearisations" = "linearizations" +"linearise" = "linearize" +"linearised" = "linearized" +"lineariser" = "linearizer" +"linearisers" = "linearizers" +"linearises" = "linearizes" +"linearising" = "linearizing" +"linearizable" = "linearizable" +"linearizably" = "linearizably" +"linearization" = "linearization" +"linearizations" = "linearizations" +"linearize" = "linearize" +"linearized" = "linearized" +"linearizer" = "linearizer" +"linearizers" = "linearizers" +"linearizes" = "linearizes" +"linearizing" = "linearizing" +"localisable" = "localizable" +"localisably" = "localizably" +"localisation" = "localization" +"localisations" = "localizations" +"localise" = "localize" +"localised" = "localized" +"localiser" = "localizer" +"localisers" = "localizers" +"localises" = "localizes" +"localising" = "localizing" +"localizable" = "localizable" +"localizably" = "localizably" +"localization" = "localization" +"localizations" = "localizations" +"localize" = "localize" +"localized" = "localized" +"localizer" = "localizer" +"localizers" = "localizers" +"localizes" = "localizes" +"localizing" = "localizing" +"materialisable" = "materializable" +"materialisably" = "materializably" +"materialisation" = "materialization" +"materialisations" = "materializations" +"materialise" = "materialize" +"materialised" = "materialized" +"materialiser" = "materializer" +"materialisers" = "materializers" +"materialises" = "materializes" +"materialising" = "materializing" +"materializable" = "materializable" +"materializably" = "materializably" +"materialization" = "materialization" +"materializations" = "materializations" +"materialize" = "materialize" +"materialized" = "materialized" +"materializer" = "materializer" +"materializers" = "materializers" +"materializes" = "materializes" +"materializing" = "materializing" +"maximisable" = "maximizable" +"maximisably" = "maximizably" +"maximisation" = "maximization" +"maximisations" = "maximizations" +"maximise" = "maximize" +"maximised" = "maximized" +"maximiser" = "maximizer" +"maximisers" = "maximizers" +"maximises" = "maximizes" +"maximising" = "maximizing" +"maximizable" = "maximizable" +"maximizably" = "maximizably" +"maximization" = "maximization" +"maximizations" = "maximizations" +"maximize" = "maximize" +"maximized" = "maximized" +"maximizer" = "maximizer" +"maximizers" = "maximizers" +"maximizes" = "maximizes" +"maximizing" = "maximizing" +"mechanisable" = "mechanizable" +"mechanisably" = "mechanizably" +"mechanisation" = "mechanization" +"mechanisations" = "mechanizations" +"mechanise" = "mechanize" +"mechanised" = "mechanized" +"mechaniser" = "mechanizer" +"mechanisers" = "mechanizers" +"mechanises" = "mechanizes" +"mechanising" = "mechanizing" +"mechanizable" = "mechanizable" +"mechanizably" = "mechanizably" +"mechanization" = "mechanization" +"mechanizations" = "mechanizations" +"mechanize" = "mechanize" +"mechanized" = "mechanized" +"mechanizer" = "mechanizer" +"mechanizers" = "mechanizers" +"mechanizes" = "mechanizes" +"mechanizing" = "mechanizing" +"memoisable" = "memoizable" +"memoisably" = "memoizably" +"memoisation" = "memoization" +"memoisations" = "memoizations" +"memoise" = "memoize" +"memoised" = "memoized" +"memoiser" = "memoizer" +"memoisers" = "memoizers" +"memoises" = "memoizes" +"memoising" = "memoizing" +"memoizable" = "memoizable" +"memoizably" = "memoizably" +"memoization" = "memoization" +"memoizations" = "memoizations" +"memoize" = "memoize" +"memoized" = "memoized" +"memoizer" = "memoizer" +"memoizers" = "memoizers" +"memoizes" = "memoizes" +"memoizing" = "memoizing" +"memorisable" = "memorizable" +"memorisably" = "memorizably" +"memorisation" = "memorization" +"memorisations" = "memorizations" +"memorise" = "memorize" +"memorised" = "memorized" +"memoriser" = "memorizer" +"memorisers" = "memorizers" +"memorises" = "memorizes" +"memorising" = "memorizing" +"memorizable" = "memorizable" +"memorizably" = "memorizably" +"memorization" = "memorization" +"memorizations" = "memorizations" +"memorize" = "memorize" +"memorized" = "memorized" +"memorizer" = "memorizer" +"memorizers" = "memorizers" +"memorizes" = "memorizes" +"memorizing" = "memorizing" +"miniaturisable" = "miniaturizable" +"miniaturisably" = "miniaturizably" +"miniaturisation" = "miniaturization" +"miniaturisations" = "miniaturizations" +"miniaturise" = "miniaturize" +"miniaturised" = "miniaturized" +"miniaturiser" = "miniaturizer" +"miniaturisers" = "miniaturizers" +"miniaturises" = "miniaturizes" +"miniaturising" = "miniaturizing" +"miniaturizable" = "miniaturizable" +"miniaturizably" = "miniaturizably" +"miniaturization" = "miniaturization" +"miniaturizations" = "miniaturizations" +"miniaturize" = "miniaturize" +"miniaturized" = "miniaturized" +"miniaturizer" = "miniaturizer" +"miniaturizers" = "miniaturizers" +"miniaturizes" = "miniaturizes" +"miniaturizing" = "miniaturizing" +"minimisable" = "minimizable" +"minimisably" = "minimizably" +"minimisation" = "minimization" +"minimisations" = "minimizations" +"minimise" = "minimize" +"minimised" = "minimized" +"minimiser" = "minimizer" +"minimisers" = "minimizers" +"minimises" = "minimizes" +"minimising" = "minimizing" +"minimizable" = "minimizable" +"minimizably" = "minimizably" +"minimization" = "minimization" +"minimizations" = "minimizations" +"minimize" = "minimize" +"minimized" = "minimized" +"minimizer" = "minimizer" +"minimizers" = "minimizers" +"minimizes" = "minimizes" +"minimizing" = "minimizing" +"mobilisable" = "mobilizable" +"mobilisably" = "mobilizably" +"mobilisation" = "mobilization" +"mobilisations" = "mobilizations" +"mobilise" = "mobilize" +"mobilised" = "mobilized" +"mobiliser" = "mobilizer" +"mobilisers" = "mobilizers" +"mobilises" = "mobilizes" +"mobilising" = "mobilizing" +"mobilizable" = "mobilizable" +"mobilizably" = "mobilizably" +"mobilization" = "mobilization" +"mobilizations" = "mobilizations" +"mobilize" = "mobilize" +"mobilized" = "mobilized" +"mobilizer" = "mobilizer" +"mobilizers" = "mobilizers" +"mobilizes" = "mobilizes" +"mobilizing" = "mobilizing" +"modernisable" = "modernizable" +"modernisably" = "modernizably" +"modernisation" = "modernization" +"modernisations" = "modernizations" +"modernise" = "modernize" +"modernised" = "modernized" +"moderniser" = "modernizer" +"modernisers" = "modernizers" +"modernises" = "modernizes" +"modernising" = "modernizing" +"modernizable" = "modernizable" +"modernizably" = "modernizably" +"modernization" = "modernization" +"modernizations" = "modernizations" +"modernize" = "modernize" +"modernized" = "modernized" +"modernizer" = "modernizer" +"modernizers" = "modernizers" +"modernizes" = "modernizes" +"modernizing" = "modernizing" +"modularisable" = "modularizable" +"modularisably" = "modularizably" +"modularisation" = "modularization" +"modularisations" = "modularizations" +"modularise" = "modularize" +"modularised" = "modularized" +"modulariser" = "modularizer" +"modularisers" = "modularizers" +"modularises" = "modularizes" +"modularising" = "modularizing" +"modularizable" = "modularizable" +"modularizably" = "modularizably" +"modularization" = "modularization" +"modularizations" = "modularizations" +"modularize" = "modularize" +"modularized" = "modularized" +"modularizer" = "modularizer" +"modularizers" = "modularizers" +"modularizes" = "modularizes" +"modularizing" = "modularizing" +"monetisable" = "monetizable" +"monetisably" = "monetizably" +"monetisation" = "monetization" +"monetisations" = "monetizations" +"monetise" = "monetize" +"monetised" = "monetized" +"monetiser" = "monetizer" +"monetisers" = "monetizers" +"monetises" = "monetizes" +"monetising" = "monetizing" +"monetizable" = "monetizable" +"monetizably" = "monetizably" +"monetization" = "monetization" +"monetizations" = "monetizations" +"monetize" = "monetize" +"monetized" = "monetized" +"monetizer" = "monetizer" +"monetizers" = "monetizers" +"monetizes" = "monetizes" +"monetizing" = "monetizing" +"monomorphisable" = "monomorphizable" +"monomorphisably" = "monomorphizably" +"monomorphisation" = "monomorphization" +"monomorphisations" = "monomorphizations" +"monomorphise" = "monomorphize" +"monomorphised" = "monomorphized" +"monomorphiser" = "monomorphizer" +"monomorphisers" = "monomorphizers" +"monomorphises" = "monomorphizes" +"monomorphising" = "monomorphizing" +"monomorphizable" = "monomorphizable" +"monomorphizably" = "monomorphizably" +"monomorphization" = "monomorphization" +"monomorphizations" = "monomorphizations" +"monomorphize" = "monomorphize" +"monomorphized" = "monomorphized" +"monomorphizer" = "monomorphizer" +"monomorphizers" = "monomorphizers" +"monomorphizes" = "monomorphizes" +"monomorphizing" = "monomorphizing" +"monopolisable" = "monopolizable" +"monopolisably" = "monopolizably" +"monopolisation" = "monopolization" +"monopolisations" = "monopolizations" +"monopolise" = "monopolize" +"monopolised" = "monopolized" +"monopoliser" = "monopolizer" +"monopolisers" = "monopolizers" +"monopolises" = "monopolizes" +"monopolising" = "monopolizing" +"monopolizable" = "monopolizable" +"monopolizably" = "monopolizably" +"monopolization" = "monopolization" +"monopolizations" = "monopolizations" +"monopolize" = "monopolize" +"monopolized" = "monopolized" +"monopolizer" = "monopolizer" +"monopolizers" = "monopolizers" +"monopolizes" = "monopolizes" +"monopolizing" = "monopolizing" +"moralisable" = "moralizable" +"moralisably" = "moralizably" +"moralisation" = "moralization" +"moralisations" = "moralizations" +"moralise" = "moralize" +"moralised" = "moralized" +"moraliser" = "moralizer" +"moralisers" = "moralizers" +"moralises" = "moralizes" +"moralising" = "moralizing" +"moralizable" = "moralizable" +"moralizably" = "moralizably" +"moralization" = "moralization" +"moralizations" = "moralizations" +"moralize" = "moralize" +"moralized" = "moralized" +"moralizer" = "moralizer" +"moralizers" = "moralizers" +"moralizes" = "moralizes" +"moralizing" = "moralizing" +"neutralisable" = "neutralizable" +"neutralisably" = "neutralizably" +"neutralisation" = "neutralization" +"neutralisations" = "neutralizations" +"neutralise" = "neutralize" +"neutralised" = "neutralized" +"neutraliser" = "neutralizer" +"neutralisers" = "neutralizers" +"neutralises" = "neutralizes" +"neutralising" = "neutralizing" +"neutralizable" = "neutralizable" +"neutralizably" = "neutralizably" +"neutralization" = "neutralization" +"neutralizations" = "neutralizations" +"neutralize" = "neutralize" +"neutralized" = "neutralized" +"neutralizer" = "neutralizer" +"neutralizers" = "neutralizers" +"neutralizes" = "neutralizes" +"neutralizing" = "neutralizing" +"normalisable" = "normalizable" +"normalisably" = "normalizably" +"normalisation" = "normalization" +"normalisations" = "normalizations" +"normalise" = "normalize" +"normalised" = "normalized" +"normaliser" = "normalizer" +"normalisers" = "normalizers" +"normalises" = "normalizes" +"normalising" = "normalizing" +"normalizable" = "normalizable" +"normalizably" = "normalizably" +"normalization" = "normalization" +"normalizations" = "normalizations" +"normalize" = "normalize" +"normalized" = "normalized" +"normalizer" = "normalizer" +"normalizers" = "normalizers" +"normalizes" = "normalizes" +"normalizing" = "normalizing" +"notarisable" = "notarizable" +"notarisably" = "notarizably" +"notarisation" = "notarization" +"notarisations" = "notarizations" +"notarise" = "notarize" +"notarised" = "notarized" +"notariser" = "notarizer" +"notarisers" = "notarizers" +"notarises" = "notarizes" +"notarising" = "notarizing" +"notarizable" = "notarizable" +"notarizably" = "notarizably" +"notarization" = "notarization" +"notarizations" = "notarizations" +"notarize" = "notarize" +"notarized" = "notarized" +"notarizer" = "notarizer" +"notarizers" = "notarizers" +"notarizes" = "notarizes" +"notarizing" = "notarizing" +"operationalisable" = "operationalizable" +"operationalisably" = "operationalizably" +"operationalisation" = "operationalization" +"operationalisations" = "operationalizations" +"operationalise" = "operationalize" +"operationalised" = "operationalized" +"operationaliser" = "operationalizer" +"operationalisers" = "operationalizers" +"operationalises" = "operationalizes" +"operationalising" = "operationalizing" +"operationalizable" = "operationalizable" +"operationalizably" = "operationalizably" +"operationalization" = "operationalization" +"operationalizations" = "operationalizations" +"operationalize" = "operationalize" +"operationalized" = "operationalized" +"operationalizer" = "operationalizer" +"operationalizers" = "operationalizers" +"operationalizes" = "operationalizes" +"operationalizing" = "operationalizing" +"optimisable" = "optimizable" +"optimisably" = "optimizably" +"optimisation" = "optimization" +"optimisations" = "optimizations" +"optimise" = "optimize" +"optimised" = "optimized" +"optimiser" = "optimizer" +"optimisers" = "optimizers" +"optimises" = "optimizes" +"optimising" = "optimizing" +"optimizable" = "optimizable" +"optimizably" = "optimizably" +"optimization" = "optimization" +"optimizations" = "optimizations" +"optimize" = "optimize" +"optimized" = "optimized" +"optimizer" = "optimizer" +"optimizers" = "optimizers" +"optimizes" = "optimizes" +"optimizing" = "optimizing" +"ordinalisable" = "ordinalizable" +"ordinalisably" = "ordinalizably" +"ordinalisation" = "ordinalization" +"ordinalisations" = "ordinalizations" +"ordinalise" = "ordinalize" +"ordinalised" = "ordinalized" +"ordinaliser" = "ordinalizer" +"ordinalisers" = "ordinalizers" +"ordinalises" = "ordinalizes" +"ordinalising" = "ordinalizing" +"ordinalizable" = "ordinalizable" +"ordinalizably" = "ordinalizably" +"ordinalization" = "ordinalization" +"ordinalizations" = "ordinalizations" +"ordinalize" = "ordinalize" +"ordinalized" = "ordinalized" +"ordinalizer" = "ordinalizer" +"ordinalizers" = "ordinalizers" +"ordinalizes" = "ordinalizes" +"ordinalizing" = "ordinalizing" +"organisable" = "organizable" +"organisably" = "organizably" +"organisation" = "organization" +"organisational" = "organizational" +"organisations" = "organizations" +"organise" = "organize" +"organised" = "organized" +"organiser" = "organizer" +"organisers" = "organizers" +"organises" = "organizes" +"organising" = "organizing" +"organizable" = "organizable" +"organizably" = "organizably" +"organization" = "organization" +"organizational" = "organizational" +"organizations" = "organizations" +"organize" = "organize" +"organized" = "organized" +"organizer" = "organizer" +"organizers" = "organizers" +"organizes" = "organizes" +"organizing" = "organizing" +"oxendict" = "oxendict" +"oxidisable" = "oxidizable" +"oxidisably" = "oxidizably" +"oxidisation" = "oxidization" +"oxidisations" = "oxidizations" +"oxidise" = "oxidize" +"oxidised" = "oxidized" +"oxidiser" = "oxidizer" +"oxidisers" = "oxidizers" +"oxidises" = "oxidizes" +"oxidising" = "oxidizing" +"oxidizable" = "oxidizable" +"oxidizably" = "oxidizably" +"oxidization" = "oxidization" +"oxidizations" = "oxidizations" +"oxidize" = "oxidize" +"oxidized" = "oxidized" +"oxidizer" = "oxidizer" +"oxidizers" = "oxidizers" +"oxidizes" = "oxidizes" +"oxidizing" = "oxidizing" +"palettisable" = "palettizable" +"palettisably" = "palettizably" +"palettisation" = "palettization" +"palettisations" = "palettizations" +"palettise" = "palettize" +"palettised" = "palettized" +"palettiser" = "palettizer" +"palettisers" = "palettizers" +"palettises" = "palettizes" +"palettising" = "palettizing" +"palettizable" = "palettizable" +"palettizably" = "palettizably" +"palettization" = "palettization" +"palettizations" = "palettizations" +"palettize" = "palettize" +"palettized" = "palettized" +"palettizer" = "palettizer" +"palettizers" = "palettizers" +"palettizes" = "palettizes" +"palettizing" = "palettizing" +"parallelisable" = "parallelizable" +"parallelisably" = "parallelizably" +"parallelisation" = "parallelization" +"parallelisations" = "parallelizations" +"parallelise" = "parallelize" +"parallelised" = "parallelized" +"paralleliser" = "parallelizer" +"parallelisers" = "parallelizers" +"parallelises" = "parallelizes" +"parallelising" = "parallelizing" +"parallelizable" = "parallelizable" +"parallelizably" = "parallelizably" +"parallelization" = "parallelization" +"parallelizations" = "parallelizations" +"parallelize" = "parallelize" +"parallelized" = "parallelized" +"parallelizer" = "parallelizer" +"parallelizers" = "parallelizers" +"parallelizes" = "parallelizes" +"parallelizing" = "parallelizing" +"parameterisable" = "parameterizable" +"parameterisably" = "parameterizably" +"parameterisation" = "parameterization" +"parameterisations" = "parameterizations" +"parameterise" = "parameterize" +"parameterised" = "parameterized" +"parameteriser" = "parameterizer" +"parameterisers" = "parameterizers" +"parameterises" = "parameterizes" +"parameterising" = "parameterizing" +"parameterizable" = "parameterizable" +"parameterizably" = "parameterizably" +"parameterization" = "parameterization" +"parameterizations" = "parameterizations" +"parameterize" = "parameterize" +"parameterized" = "parameterized" +"parameterizer" = "parameterizer" +"parameterizers" = "parameterizers" +"parameterizes" = "parameterizes" +"parameterizing" = "parameterizing" +"parametrisable" = "parametrizable" +"parametrisably" = "parametrizably" +"parametrisation" = "parametrization" +"parametrisations" = "parametrizations" +"parametrise" = "parametrize" +"parametrised" = "parametrized" +"parametriser" = "parametrizer" +"parametrisers" = "parametrizers" +"parametrises" = "parametrizes" +"parametrising" = "parametrizing" +"parametrizable" = "parametrizable" +"parametrizably" = "parametrizably" +"parametrization" = "parametrization" +"parametrizations" = "parametrizations" +"parametrize" = "parametrize" +"parametrized" = "parametrized" +"parametrizer" = "parametrizer" +"parametrizers" = "parametrizers" +"parametrizes" = "parametrizes" +"parametrizing" = "parametrizing" +"parenthesisable" = "parenthesizable" +"parenthesisably" = "parenthesizably" +"parenthesisation" = "parenthesization" +"parenthesisations" = "parenthesizations" +"parenthesise" = "parenthesize" +"parenthesised" = "parenthesized" +"parenthesiser" = "parenthesizer" +"parenthesisers" = "parenthesizers" +"parenthesises" = "parenthesizes" +"parenthesising" = "parenthesizing" +"parenthesizable" = "parenthesizable" +"parenthesizably" = "parenthesizably" +"parenthesization" = "parenthesization" +"parenthesizations" = "parenthesizations" +"parenthesize" = "parenthesize" +"parenthesized" = "parenthesized" +"parenthesizer" = "parenthesizer" +"parenthesizers" = "parenthesizers" +"parenthesizes" = "parenthesizes" +"parenthesizing" = "parenthesizing" +"penalisable" = "penalizable" +"penalisably" = "penalizably" +"penalisation" = "penalization" +"penalisations" = "penalizations" +"penalise" = "penalize" +"penalised" = "penalized" +"penaliser" = "penalizer" +"penalisers" = "penalizers" +"penalises" = "penalizes" +"penalising" = "penalizing" +"penalizable" = "penalizable" +"penalizably" = "penalizably" +"penalization" = "penalization" +"penalizations" = "penalizations" +"penalize" = "penalize" +"penalized" = "penalized" +"penalizer" = "penalizer" +"penalizers" = "penalizers" +"penalizes" = "penalizes" +"penalizing" = "penalizing" +"personalisable" = "personalizable" +"personalisably" = "personalizably" +"personalisation" = "personalization" +"personalisations" = "personalizations" +"personalise" = "personalize" +"personalised" = "personalized" +"personaliser" = "personalizer" +"personalisers" = "personalizers" +"personalises" = "personalizes" +"personalising" = "personalizing" +"personalizable" = "personalizable" +"personalizably" = "personalizably" +"personalization" = "personalization" +"personalizations" = "personalizations" +"personalize" = "personalize" +"personalized" = "personalized" +"personalizer" = "personalizer" +"personalizers" = "personalizers" +"personalizes" = "personalizes" +"personalizing" = "personalizing" +"pluralisable" = "pluralizable" +"pluralisably" = "pluralizably" +"pluralisation" = "pluralization" +"pluralisations" = "pluralizations" +"pluralise" = "pluralize" +"pluralised" = "pluralized" +"pluraliser" = "pluralizer" +"pluralisers" = "pluralizers" +"pluralises" = "pluralizes" +"pluralising" = "pluralizing" +"pluralizable" = "pluralizable" +"pluralizably" = "pluralizably" +"pluralization" = "pluralization" +"pluralizations" = "pluralizations" +"pluralize" = "pluralize" +"pluralized" = "pluralized" +"pluralizer" = "pluralizer" +"pluralizers" = "pluralizers" +"pluralizes" = "pluralizes" +"pluralizing" = "pluralizing" +"popularisable" = "popularizable" +"popularisably" = "popularizably" +"popularisation" = "popularization" +"popularisations" = "popularizations" +"popularise" = "popularize" +"popularised" = "popularized" +"populariser" = "popularizer" +"popularisers" = "popularizers" +"popularises" = "popularizes" +"popularising" = "popularizing" +"popularizable" = "popularizable" +"popularizably" = "popularizably" +"popularization" = "popularization" +"popularizations" = "popularizations" +"popularize" = "popularize" +"popularized" = "popularized" +"popularizer" = "popularizer" +"popularizers" = "popularizers" +"popularizes" = "popularizes" +"popularizing" = "popularizing" +"prioritisable" = "prioritizable" +"prioritisably" = "prioritizably" +"prioritisation" = "prioritization" +"prioritisations" = "prioritizations" +"prioritise" = "prioritize" +"prioritised" = "prioritized" +"prioritiser" = "prioritizer" +"prioritisers" = "prioritizers" +"prioritises" = "prioritizes" +"prioritising" = "prioritizing" +"prioritizable" = "prioritizable" +"prioritizably" = "prioritizably" +"prioritization" = "prioritization" +"prioritizations" = "prioritizations" +"prioritize" = "prioritize" +"prioritized" = "prioritized" +"prioritizer" = "prioritizer" +"prioritizers" = "prioritizers" +"prioritizes" = "prioritizes" +"prioritizing" = "prioritizing" +"privatisable" = "privatizable" +"privatisably" = "privatizably" +"privatisation" = "privatization" +"privatisations" = "privatizations" +"privatise" = "privatize" +"privatised" = "privatized" +"privatiser" = "privatizer" +"privatisers" = "privatizers" +"privatises" = "privatizes" +"privatising" = "privatizing" +"privatizable" = "privatizable" +"privatizably" = "privatizably" +"privatization" = "privatization" +"privatizations" = "privatizations" +"privatize" = "privatize" +"privatized" = "privatized" +"privatizer" = "privatizer" +"privatizers" = "privatizers" +"privatizes" = "privatizes" +"privatizing" = "privatizing" +"productisable" = "productizable" +"productisably" = "productizably" +"productisation" = "productization" +"productisations" = "productizations" +"productise" = "productize" +"productised" = "productized" +"productiser" = "productizer" +"productisers" = "productizers" +"productises" = "productizes" +"productising" = "productizing" +"productizable" = "productizable" +"productizably" = "productizably" +"productization" = "productization" +"productizations" = "productizations" +"productize" = "productize" +"productized" = "productized" +"productizer" = "productizer" +"productizers" = "productizers" +"productizes" = "productizes" +"productizing" = "productizing" +"publicisable" = "publicizable" +"publicisably" = "publicizably" +"publicisation" = "publicization" +"publicisations" = "publicizations" +"publicise" = "publicize" +"publicised" = "publicized" +"publiciser" = "publicizer" +"publicisers" = "publicizers" +"publicises" = "publicizes" +"publicising" = "publicizing" +"publicizable" = "publicizable" +"publicizably" = "publicizably" +"publicization" = "publicization" +"publicizations" = "publicizations" +"publicize" = "publicize" +"publicized" = "publicized" +"publicizer" = "publicizer" +"publicizers" = "publicizers" +"publicizes" = "publicizes" +"publicizing" = "publicizing" +"quantisable" = "quantizable" +"quantisably" = "quantizably" +"quantisation" = "quantization" +"quantisations" = "quantizations" +"quantise" = "quantize" +"quantised" = "quantized" +"quantiser" = "quantizer" +"quantisers" = "quantizers" +"quantises" = "quantizes" +"quantising" = "quantizing" +"quantizable" = "quantizable" +"quantizably" = "quantizably" +"quantization" = "quantization" +"quantizations" = "quantizations" +"quantize" = "quantize" +"quantized" = "quantized" +"quantizer" = "quantizer" +"quantizers" = "quantizers" +"quantizes" = "quantizes" +"quantizing" = "quantizing" +"randomisable" = "randomizable" +"randomisably" = "randomizably" +"randomisation" = "randomization" +"randomisations" = "randomizations" +"randomise" = "randomize" +"randomised" = "randomized" +"randomiser" = "randomizer" +"randomisers" = "randomizers" +"randomises" = "randomizes" +"randomising" = "randomizing" +"randomizable" = "randomizable" +"randomizably" = "randomizably" +"randomization" = "randomization" +"randomizations" = "randomizations" +"randomize" = "randomize" +"randomized" = "randomized" +"randomizer" = "randomizer" +"randomizers" = "randomizers" +"randomizes" = "randomizes" +"randomizing" = "randomizing" +"rasterisable" = "rasterizable" +"rasterisably" = "rasterizably" +"rasterisation" = "rasterization" +"rasterisations" = "rasterizations" +"rasterise" = "rasterize" +"rasterised" = "rasterized" +"rasteriser" = "rasterizer" +"rasterisers" = "rasterizers" +"rasterises" = "rasterizes" +"rasterising" = "rasterizing" +"rasterizable" = "rasterizable" +"rasterizably" = "rasterizably" +"rasterization" = "rasterization" +"rasterizations" = "rasterizations" +"rasterize" = "rasterize" +"rasterized" = "rasterized" +"rasterizer" = "rasterizer" +"rasterizers" = "rasterizers" +"rasterizes" = "rasterizes" +"rasterizing" = "rasterizing" +"rationalisable" = "rationalizable" +"rationalisably" = "rationalizably" +"rationalisation" = "rationalization" +"rationalisations" = "rationalizations" +"rationalise" = "rationalize" +"rationalised" = "rationalized" +"rationaliser" = "rationalizer" +"rationalisers" = "rationalizers" +"rationalises" = "rationalizes" +"rationalising" = "rationalizing" +"rationalizable" = "rationalizable" +"rationalizably" = "rationalizably" +"rationalization" = "rationalization" +"rationalizations" = "rationalizations" +"rationalize" = "rationalize" +"rationalized" = "rationalized" +"rationalizer" = "rationalizer" +"rationalizers" = "rationalizers" +"rationalizes" = "rationalizes" +"rationalizing" = "rationalizing" +"realisable" = "realizable" +"realisably" = "realizably" +"realisation" = "realization" +"realisations" = "realizations" +"realise" = "realize" +"realised" = "realized" +"realiser" = "realizer" +"realisers" = "realizers" +"realises" = "realizes" +"realising" = "realizing" +"realizable" = "realizable" +"realizably" = "realizably" +"realization" = "realization" +"realizations" = "realizations" +"realize" = "realize" +"realized" = "realized" +"realizer" = "realizer" +"realizers" = "realizers" +"realizes" = "realizes" +"realizing" = "realizing" +"recognisable" = "recognizable" +"recognisably" = "recognizably" +"recognisation" = "recognization" +"recognisations" = "recognizations" +"recognise" = "recognize" +"recognised" = "recognized" +"recogniser" = "recognizer" +"recognisers" = "recognizers" +"recognises" = "recognizes" +"recognising" = "recognizing" +"recognizable" = "recognizable" +"recognizably" = "recognizably" +"recognization" = "recognization" +"recognizations" = "recognizations" +"recognize" = "recognize" +"recognized" = "recognized" +"recognizer" = "recognizer" +"recognizers" = "recognizers" +"recognizes" = "recognizes" +"recognizing" = "recognizing" +"regularisable" = "regularizable" +"regularisably" = "regularizably" +"regularisation" = "regularization" +"regularisations" = "regularizations" +"regularise" = "regularize" +"regularised" = "regularized" +"regulariser" = "regularizer" +"regularisers" = "regularizers" +"regularises" = "regularizes" +"regularising" = "regularizing" +"regularizable" = "regularizable" +"regularizably" = "regularizably" +"regularization" = "regularization" +"regularizations" = "regularizations" +"regularize" = "regularize" +"regularized" = "regularized" +"regularizer" = "regularizer" +"regularizers" = "regularizers" +"regularizes" = "regularizes" +"regularizing" = "regularizing" +"reinitialisable" = "reinitializable" +"reinitialisably" = "reinitializably" +"reinitialisation" = "reinitialization" +"reinitialisations" = "reinitializations" +"reinitialise" = "reinitialize" +"reinitialised" = "reinitialized" +"reinitialiser" = "reinitializer" +"reinitialisers" = "reinitializers" +"reinitialises" = "reinitializes" +"reinitialising" = "reinitializing" +"reinitializable" = "reinitializable" +"reinitializably" = "reinitializably" +"reinitialization" = "reinitialization" +"reinitializations" = "reinitializations" +"reinitialize" = "reinitialize" +"reinitialized" = "reinitialized" +"reinitializer" = "reinitializer" +"reinitializers" = "reinitializers" +"reinitializes" = "reinitializes" +"reinitializing" = "reinitializing" +"reorganisable" = "reorganizable" +"reorganisably" = "reorganizably" +"reorganisation" = "reorganization" +"reorganisations" = "reorganizations" +"reorganise" = "reorganize" +"reorganised" = "reorganized" +"reorganiser" = "reorganizer" +"reorganisers" = "reorganizers" +"reorganises" = "reorganizes" +"reorganising" = "reorganizing" +"reorganizable" = "reorganizable" +"reorganizably" = "reorganizably" +"reorganization" = "reorganization" +"reorganizations" = "reorganizations" +"reorganize" = "reorganize" +"reorganized" = "reorganized" +"reorganizer" = "reorganizer" +"reorganizers" = "reorganizers" +"reorganizes" = "reorganizes" +"reorganizing" = "reorganizing" +"reserialisable" = "reserializable" +"reserialisably" = "reserializably" +"reserialisation" = "reserialization" +"reserialisations" = "reserializations" +"reserialise" = "reserialize" +"reserialised" = "reserialized" +"reserialiser" = "reserializer" +"reserialisers" = "reserializers" +"reserialises" = "reserializes" +"reserialising" = "reserializing" +"reserializable" = "reserializable" +"reserializably" = "reserializably" +"reserialization" = "reserialization" +"reserializations" = "reserializations" +"reserialize" = "reserialize" +"reserialized" = "reserialized" +"reserializer" = "reserializer" +"reserializers" = "reserializers" +"reserializes" = "reserializes" +"reserializing" = "reserializing" +"resynchronisable" = "resynchronizable" +"resynchronisably" = "resynchronizably" +"resynchronisation" = "resynchronization" +"resynchronisations" = "resynchronizations" +"resynchronise" = "resynchronize" +"resynchronised" = "resynchronized" +"resynchroniser" = "resynchronizer" +"resynchronisers" = "resynchronizers" +"resynchronises" = "resynchronizes" +"resynchronising" = "resynchronizing" +"resynchronizable" = "resynchronizable" +"resynchronizably" = "resynchronizably" +"resynchronization" = "resynchronization" +"resynchronizations" = "resynchronizations" +"resynchronize" = "resynchronize" +"resynchronized" = "resynchronized" +"resynchronizer" = "resynchronizer" +"resynchronizers" = "resynchronizers" +"resynchronizes" = "resynchronizes" +"resynchronizing" = "resynchronizing" +"retokenisable" = "retokenizable" +"retokenisably" = "retokenizably" +"retokenisation" = "retokenization" +"retokenisations" = "retokenizations" +"retokenise" = "retokenize" +"retokenised" = "retokenized" +"retokeniser" = "retokenizer" +"retokenisers" = "retokenizers" +"retokenises" = "retokenizes" +"retokenising" = "retokenizing" +"retokenizable" = "retokenizable" +"retokenizably" = "retokenizably" +"retokenization" = "retokenization" +"retokenizations" = "retokenizations" +"retokenize" = "retokenize" +"retokenized" = "retokenized" +"retokenizer" = "retokenizer" +"retokenizers" = "retokenizers" +"retokenizes" = "retokenizes" +"retokenizing" = "retokenizing" +"romanticisable" = "romanticizable" +"romanticisably" = "romanticizably" +"romanticisation" = "romanticization" +"romanticisations" = "romanticizations" +"romanticise" = "romanticize" +"romanticised" = "romanticized" +"romanticiser" = "romanticizer" +"romanticisers" = "romanticizers" +"romanticises" = "romanticizes" +"romanticising" = "romanticizing" +"romanticizable" = "romanticizable" +"romanticizably" = "romanticizably" +"romanticization" = "romanticization" +"romanticizations" = "romanticizations" +"romanticize" = "romanticize" +"romanticized" = "romanticized" +"romanticizer" = "romanticizer" +"romanticizers" = "romanticizers" +"romanticizes" = "romanticizes" +"romanticizing" = "romanticizing" +"sanitisable" = "sanitizable" +"sanitisably" = "sanitizably" +"sanitisation" = "sanitization" +"sanitisations" = "sanitizations" +"sanitise" = "sanitize" +"sanitised" = "sanitized" +"sanitiser" = "sanitizer" +"sanitisers" = "sanitizers" +"sanitises" = "sanitizes" +"sanitising" = "sanitizing" +"sanitizable" = "sanitizable" +"sanitizably" = "sanitizably" +"sanitization" = "sanitization" +"sanitizations" = "sanitizations" +"sanitize" = "sanitize" +"sanitized" = "sanitized" +"sanitizer" = "sanitizer" +"sanitizers" = "sanitizers" +"sanitizes" = "sanitizes" +"sanitizing" = "sanitizing" +"scrutinisable" = "scrutinizable" +"scrutinisably" = "scrutinizably" +"scrutinisation" = "scrutinization" +"scrutinisations" = "scrutinizations" +"scrutinise" = "scrutinize" +"scrutinised" = "scrutinized" +"scrutiniser" = "scrutinizer" +"scrutinisers" = "scrutinizers" +"scrutinises" = "scrutinizes" +"scrutinising" = "scrutinizing" +"scrutinizable" = "scrutinizable" +"scrutinizably" = "scrutinizably" +"scrutinization" = "scrutinization" +"scrutinizations" = "scrutinizations" +"scrutinize" = "scrutinize" +"scrutinized" = "scrutinized" +"scrutinizer" = "scrutinizer" +"scrutinizers" = "scrutinizers" +"scrutinizes" = "scrutinizes" +"scrutinizing" = "scrutinizing" +"serialisable" = "serializable" +"serialisably" = "serializably" +"serialisation" = "serialization" +"serialisations" = "serializations" +"serialise" = "serialize" +"serialised" = "serialized" +"serialiser" = "serializer" +"serialisers" = "serializers" +"serialises" = "serializes" +"serialising" = "serializing" +"serializable" = "serializable" +"serializably" = "serializably" +"serialization" = "serialization" +"serializations" = "serializations" +"serialize" = "serialize" +"serialized" = "serialized" +"serializer" = "serializer" +"serializers" = "serializers" +"serializes" = "serializes" +"serializing" = "serializing" +"sexualisable" = "sexualizable" +"sexualisably" = "sexualizably" +"sexualisation" = "sexualization" +"sexualisations" = "sexualizations" +"sexualise" = "sexualize" +"sexualised" = "sexualized" +"sexualiser" = "sexualizer" +"sexualisers" = "sexualizers" +"sexualises" = "sexualizes" +"sexualising" = "sexualizing" +"sexualizable" = "sexualizable" +"sexualizably" = "sexualizably" +"sexualization" = "sexualization" +"sexualizations" = "sexualizations" +"sexualize" = "sexualize" +"sexualized" = "sexualized" +"sexualizer" = "sexualizer" +"sexualizers" = "sexualizers" +"sexualizes" = "sexualizes" +"sexualizing" = "sexualizing" +"singularisable" = "singularizable" +"singularisably" = "singularizably" +"singularisation" = "singularization" +"singularisations" = "singularizations" +"singularise" = "singularize" +"singularised" = "singularized" +"singulariser" = "singularizer" +"singularisers" = "singularizers" +"singularises" = "singularizes" +"singularising" = "singularizing" +"singularizable" = "singularizable" +"singularizably" = "singularizably" +"singularization" = "singularization" +"singularizations" = "singularizations" +"singularize" = "singularize" +"singularized" = "singularized" +"singularizer" = "singularizer" +"singularizers" = "singularizers" +"singularizes" = "singularizes" +"singularizing" = "singularizing" +"socialisable" = "socializable" +"socialisably" = "socializably" +"socialisation" = "socialization" +"socialisations" = "socializations" +"socialise" = "socialize" +"socialised" = "socialized" +"socialiser" = "socializer" +"socialisers" = "socializers" +"socialises" = "socializes" +"socialising" = "socializing" +"socializable" = "socializable" +"socializably" = "socializably" +"socialization" = "socialization" +"socializations" = "socializations" +"socialize" = "socialize" +"socialized" = "socialized" +"socializer" = "socializer" +"socializers" = "socializers" +"socializes" = "socializes" +"socializing" = "socializing" +"specialisable" = "specializable" +"specialisably" = "specializably" +"specialisation" = "specialization" +"specialisations" = "specializations" +"specialise" = "specialize" +"specialised" = "specialized" +"specialiser" = "specializer" +"specialisers" = "specializers" +"specialises" = "specializes" +"specialising" = "specializing" +"specializable" = "specializable" +"specializably" = "specializably" +"specialization" = "specialization" +"specializations" = "specializations" +"specialize" = "specialize" +"specialized" = "specialized" +"specializer" = "specializer" +"specializers" = "specializers" +"specializes" = "specializes" +"specializing" = "specializing" +"stabilisable" = "stabilizable" +"stabilisably" = "stabilizably" +"stabilisation" = "stabilization" +"stabilisations" = "stabilizations" +"stabilise" = "stabilize" +"stabilised" = "stabilized" +"stabiliser" = "stabilizer" +"stabilisers" = "stabilizers" +"stabilises" = "stabilizes" +"stabilising" = "stabilizing" +"stabilizable" = "stabilizable" +"stabilizably" = "stabilizably" +"stabilization" = "stabilization" +"stabilizations" = "stabilizations" +"stabilize" = "stabilize" +"stabilized" = "stabilized" +"stabilizer" = "stabilizer" +"stabilizers" = "stabilizers" +"stabilizes" = "stabilizes" +"stabilizing" = "stabilizing" +"standardisable" = "standardizable" +"standardisably" = "standardizably" +"standardisation" = "standardization" +"standardisations" = "standardizations" +"standardise" = "standardize" +"standardised" = "standardized" +"standardiser" = "standardizer" +"standardisers" = "standardizers" +"standardises" = "standardizes" +"standardising" = "standardizing" +"standardizable" = "standardizable" +"standardizably" = "standardizably" +"standardization" = "standardization" +"standardizations" = "standardizations" +"standardize" = "standardize" +"standardized" = "standardized" +"standardizer" = "standardizer" +"standardizers" = "standardizers" +"standardizes" = "standardizes" +"standardizing" = "standardizing" +"stylisable" = "stylizable" +"stylisably" = "stylizably" +"stylisation" = "stylization" +"stylisations" = "stylizations" +"stylise" = "stylize" +"stylised" = "stylized" +"styliser" = "stylizer" +"stylisers" = "stylizers" +"stylises" = "stylizes" +"stylising" = "stylizing" +"stylizable" = "stylizable" +"stylizably" = "stylizably" +"stylization" = "stylization" +"stylizations" = "stylizations" +"stylize" = "stylize" +"stylized" = "stylized" +"stylizer" = "stylizer" +"stylizers" = "stylizers" +"stylizes" = "stylizes" +"stylizing" = "stylizing" +"subsidisable" = "subsidizable" +"subsidisably" = "subsidizably" +"subsidisation" = "subsidization" +"subsidisations" = "subsidizations" +"subsidise" = "subsidize" +"subsidised" = "subsidized" +"subsidiser" = "subsidizer" +"subsidisers" = "subsidizers" +"subsidises" = "subsidizes" +"subsidising" = "subsidizing" +"subsidizable" = "subsidizable" +"subsidizably" = "subsidizably" +"subsidization" = "subsidization" +"subsidizations" = "subsidizations" +"subsidize" = "subsidize" +"subsidized" = "subsidized" +"subsidizer" = "subsidizer" +"subsidizers" = "subsidizers" +"subsidizes" = "subsidizes" +"subsidizing" = "subsidizing" +"summarisable" = "summarizable" +"summarisably" = "summarizably" +"summarisation" = "summarization" +"summarisations" = "summarizations" +"summarise" = "summarize" +"summarised" = "summarized" +"summariser" = "summarizer" +"summarisers" = "summarizers" +"summarises" = "summarizes" +"summarising" = "summarizing" +"summarizable" = "summarizable" +"summarizably" = "summarizably" +"summarization" = "summarization" +"summarizations" = "summarizations" +"summarize" = "summarize" +"summarized" = "summarized" +"summarizer" = "summarizer" +"summarizers" = "summarizers" +"summarizes" = "summarizes" +"summarizing" = "summarizing" +"symbolisable" = "symbolizable" +"symbolisably" = "symbolizably" +"symbolisation" = "symbolization" +"symbolisations" = "symbolizations" +"symbolise" = "symbolize" +"symbolised" = "symbolized" +"symboliser" = "symbolizer" +"symbolisers" = "symbolizers" +"symbolises" = "symbolizes" +"symbolising" = "symbolizing" +"symbolizable" = "symbolizable" +"symbolizably" = "symbolizably" +"symbolization" = "symbolization" +"symbolizations" = "symbolizations" +"symbolize" = "symbolize" +"symbolized" = "symbolized" +"symbolizer" = "symbolizer" +"symbolizers" = "symbolizers" +"symbolizes" = "symbolizes" +"symbolizing" = "symbolizing" +"synchronisable" = "synchronizable" +"synchronisably" = "synchronizably" +"synchronisation" = "synchronization" +"synchronisations" = "synchronizations" +"synchronise" = "synchronize" +"synchronised" = "synchronized" +"synchroniser" = "synchronizer" +"synchronisers" = "synchronizers" +"synchronises" = "synchronizes" +"synchronising" = "synchronizing" +"synchronizable" = "synchronizable" +"synchronizably" = "synchronizably" +"synchronization" = "synchronization" +"synchronizations" = "synchronizations" +"synchronize" = "synchronize" +"synchronized" = "synchronized" +"synchronizer" = "synchronizer" +"synchronizers" = "synchronizers" +"synchronizes" = "synchronizes" +"synchronizing" = "synchronizing" +"synthesisable" = "synthesizable" +"synthesisably" = "synthesizably" +"synthesisation" = "synthesization" +"synthesisations" = "synthesizations" +"synthesise" = "synthesize" +"synthesised" = "synthesized" +"synthesiser" = "synthesizer" +"synthesisers" = "synthesizers" +"synthesises" = "synthesizes" +"synthesising" = "synthesizing" +"synthesizable" = "synthesizable" +"synthesizably" = "synthesizably" +"synthesization" = "synthesization" +"synthesizations" = "synthesizations" +"synthesize" = "synthesize" +"synthesized" = "synthesized" +"synthesizer" = "synthesizer" +"synthesizers" = "synthesizers" +"synthesizes" = "synthesizes" +"synthesizing" = "synthesizing" +"systematisable" = "systematizable" +"systematisably" = "systematizably" +"systematisation" = "systematization" +"systematisations" = "systematizations" +"systematise" = "systematize" +"systematised" = "systematized" +"systematiser" = "systematizer" +"systematisers" = "systematizers" +"systematises" = "systematizes" +"systematising" = "systematizing" +"systematizable" = "systematizable" +"systematizably" = "systematizably" +"systematization" = "systematization" +"systematizations" = "systematizations" +"systematize" = "systematize" +"systematized" = "systematized" +"systematizer" = "systematizer" +"systematizers" = "systematizers" +"systematizes" = "systematizes" +"systematizing" = "systematizing" +"systemisable" = "systemizable" +"systemisably" = "systemizably" +"systemisation" = "systemization" +"systemisations" = "systemizations" +"systemise" = "systemize" +"systemised" = "systemized" +"systemiser" = "systemizer" +"systemisers" = "systemizers" +"systemises" = "systemizes" +"systemising" = "systemizing" +"systemizable" = "systemizable" +"systemizably" = "systemizably" +"systemization" = "systemization" +"systemizations" = "systemizations" +"systemize" = "systemize" +"systemized" = "systemized" +"systemizer" = "systemizer" +"systemizers" = "systemizers" +"systemizes" = "systemizes" +"systemizing" = "systemizing" +"templatisable" = "templatizable" +"templatisably" = "templatizably" +"templatisation" = "templatization" +"templatisations" = "templatizations" +"templatise" = "templatize" +"templatised" = "templatized" +"templatiser" = "templatizer" +"templatisers" = "templatizers" +"templatises" = "templatizes" +"templatising" = "templatizing" +"templatizable" = "templatizable" +"templatizably" = "templatizably" +"templatization" = "templatization" +"templatizations" = "templatizations" +"templatize" = "templatize" +"templatized" = "templatized" +"templatizer" = "templatizer" +"templatizers" = "templatizers" +"templatizes" = "templatizes" +"templatizing" = "templatizing" +"terminalisable" = "terminalizable" +"terminalisably" = "terminalizably" +"terminalisation" = "terminalization" +"terminalisations" = "terminalizations" +"terminalise" = "terminalize" +"terminalised" = "terminalized" +"terminaliser" = "terminalizer" +"terminalisers" = "terminalizers" +"terminalises" = "terminalizes" +"terminalising" = "terminalizing" +"terminalizable" = "terminalizable" +"terminalizably" = "terminalizably" +"terminalization" = "terminalization" +"terminalizations" = "terminalizations" +"terminalize" = "terminalize" +"terminalized" = "terminalized" +"terminalizer" = "terminalizer" +"terminalizers" = "terminalizers" +"terminalizes" = "terminalizes" +"terminalizing" = "terminalizing" +"theorisable" = "theorizable" +"theorisably" = "theorizably" +"theorisation" = "theorization" +"theorisations" = "theorizations" +"theorise" = "theorize" +"theorised" = "theorized" +"theoriser" = "theorizer" +"theorisers" = "theorizers" +"theorises" = "theorizes" +"theorising" = "theorizing" +"theorizable" = "theorizable" +"theorizably" = "theorizably" +"theorization" = "theorization" +"theorizations" = "theorizations" +"theorize" = "theorize" +"theorized" = "theorized" +"theorizer" = "theorizer" +"theorizers" = "theorizers" +"theorizes" = "theorizes" +"theorizing" = "theorizing" +"tokenisable" = "tokenizable" +"tokenisably" = "tokenizably" +"tokenisation" = "tokenization" +"tokenisations" = "tokenizations" +"tokenise" = "tokenize" +"tokenised" = "tokenized" +"tokeniser" = "tokenizer" +"tokenisers" = "tokenizers" +"tokenises" = "tokenizes" +"tokenising" = "tokenizing" +"tokenizable" = "tokenizable" +"tokenizably" = "tokenizably" +"tokenization" = "tokenization" +"tokenizations" = "tokenizations" +"tokenize" = "tokenize" +"tokenized" = "tokenized" +"tokenizer" = "tokenizer" +"tokenizers" = "tokenizers" +"tokenizes" = "tokenizes" +"tokenizing" = "tokenizing" +"trivialisable" = "trivializable" +"trivialisably" = "trivializably" +"trivialisation" = "trivialization" +"trivialisations" = "trivializations" +"trivialise" = "trivialize" +"trivialised" = "trivialized" +"trivialiser" = "trivializer" +"trivialisers" = "trivializers" +"trivialises" = "trivializes" +"trivialising" = "trivializing" +"trivializable" = "trivializable" +"trivializably" = "trivializably" +"trivialization" = "trivialization" +"trivializations" = "trivializations" +"trivialize" = "trivialize" +"trivialized" = "trivialized" +"trivializer" = "trivializer" +"trivializers" = "trivializers" +"trivializes" = "trivializes" +"trivializing" = "trivializing" +"unauthorisable" = "unauthorizable" +"unauthorisably" = "unauthorizably" +"unauthorisation" = "unauthorization" +"unauthorisations" = "unauthorizations" +"unauthorise" = "unauthorize" +"unauthorised" = "unauthorized" +"unauthoriser" = "unauthorizer" +"unauthorisers" = "unauthorizers" +"unauthorises" = "unauthorizes" +"unauthorising" = "unauthorizing" +"unauthorizable" = "unauthorizable" +"unauthorizably" = "unauthorizably" +"unauthorization" = "unauthorization" +"unauthorizations" = "unauthorizations" +"unauthorize" = "unauthorize" +"unauthorized" = "unauthorized" +"unauthorizer" = "unauthorizer" +"unauthorizers" = "unauthorizers" +"unauthorizes" = "unauthorizes" +"unauthorizing" = "unauthorizing" +"uncategorisable" = "uncategorizable" +"uncategorisably" = "uncategorizably" +"uncategorisation" = "uncategorization" +"uncategorisations" = "uncategorizations" +"uncategorise" = "uncategorize" +"uncategorised" = "uncategorized" +"uncategoriser" = "uncategorizer" +"uncategorisers" = "uncategorizers" +"uncategorises" = "uncategorizes" +"uncategorising" = "uncategorizing" +"uncategorizable" = "uncategorizable" +"uncategorizably" = "uncategorizably" +"uncategorization" = "uncategorization" +"uncategorizations" = "uncategorizations" +"uncategorize" = "uncategorize" +"uncategorized" = "uncategorized" +"uncategorizer" = "uncategorizer" +"uncategorizers" = "uncategorizers" +"uncategorizes" = "uncategorizes" +"uncategorizing" = "uncategorizing" +"underutilisable" = "underutilizable" +"underutilisably" = "underutilizably" +"underutilisation" = "underutilization" +"underutilisations" = "underutilizations" +"underutilise" = "underutilize" +"underutilised" = "underutilized" +"underutiliser" = "underutilizer" +"underutilisers" = "underutilizers" +"underutilises" = "underutilizes" +"underutilising" = "underutilizing" +"underutilizable" = "underutilizable" +"underutilizably" = "underutilizably" +"underutilization" = "underutilization" +"underutilizations" = "underutilizations" +"underutilize" = "underutilize" +"underutilized" = "underutilized" +"underutilizer" = "underutilizer" +"underutilizers" = "underutilizers" +"underutilizes" = "underutilizes" +"underutilizing" = "underutilizing" +"uninitialisable" = "uninitializable" +"uninitialisably" = "uninitializably" +"uninitialisation" = "uninitialization" +"uninitialisations" = "uninitializations" +"uninitialise" = "uninitialize" +"uninitialised" = "uninitialized" +"uninitialiser" = "uninitializer" +"uninitialisers" = "uninitializers" +"uninitialises" = "uninitializes" +"uninitialising" = "uninitializing" +"uninitializable" = "uninitializable" +"uninitializably" = "uninitializably" +"uninitialization" = "uninitialization" +"uninitializations" = "uninitializations" +"uninitialize" = "uninitialize" +"uninitialized" = "uninitialized" +"uninitializer" = "uninitializer" +"uninitializers" = "uninitializers" +"uninitializes" = "uninitializes" +"uninitializing" = "uninitializing" +"unlocalisable" = "unlocalizable" +"unlocalisably" = "unlocalizably" +"unlocalisation" = "unlocalization" +"unlocalisations" = "unlocalizations" +"unlocalise" = "unlocalize" +"unlocalised" = "unlocalized" +"unlocaliser" = "unlocalizer" +"unlocalisers" = "unlocalizers" +"unlocalises" = "unlocalizes" +"unlocalising" = "unlocalizing" +"unlocalizable" = "unlocalizable" +"unlocalizably" = "unlocalizably" +"unlocalization" = "unlocalization" +"unlocalizations" = "unlocalizations" +"unlocalize" = "unlocalize" +"unlocalized" = "unlocalized" +"unlocalizer" = "unlocalizer" +"unlocalizers" = "unlocalizers" +"unlocalizes" = "unlocalizes" +"unlocalizing" = "unlocalizing" +"unoptimisable" = "unoptimizable" +"unoptimisably" = "unoptimizably" +"unoptimisation" = "unoptimization" +"unoptimisations" = "unoptimizations" +"unoptimise" = "unoptimize" +"unoptimised" = "unoptimized" +"unoptimiser" = "unoptimizer" +"unoptimisers" = "unoptimizers" +"unoptimises" = "unoptimizes" +"unoptimising" = "unoptimizing" +"unoptimizable" = "unoptimizable" +"unoptimizably" = "unoptimizably" +"unoptimization" = "unoptimization" +"unoptimizations" = "unoptimizations" +"unoptimize" = "unoptimize" +"unoptimized" = "unoptimized" +"unoptimizer" = "unoptimizer" +"unoptimizers" = "unoptimizers" +"unoptimizes" = "unoptimizes" +"unoptimizing" = "unoptimizing" +"unparameterisable" = "unparameterizable" +"unparameterisably" = "unparameterizably" +"unparameterisation" = "unparameterization" +"unparameterisations" = "unparameterizations" +"unparameterise" = "unparameterize" +"unparameterised" = "unparameterized" +"unparameteriser" = "unparameterizer" +"unparameterisers" = "unparameterizers" +"unparameterises" = "unparameterizes" +"unparameterising" = "unparameterizing" +"unparameterizable" = "unparameterizable" +"unparameterizably" = "unparameterizably" +"unparameterization" = "unparameterization" +"unparameterizations" = "unparameterizations" +"unparameterize" = "unparameterize" +"unparameterized" = "unparameterized" +"unparameterizer" = "unparameterizer" +"unparameterizers" = "unparameterizers" +"unparameterizes" = "unparameterizes" +"unparameterizing" = "unparameterizing" +"unrecognisable" = "unrecognizable" +"unrecognisably" = "unrecognizably" +"unrecognisation" = "unrecognization" +"unrecognisations" = "unrecognizations" +"unrecognise" = "unrecognize" +"unrecognised" = "unrecognized" +"unrecogniser" = "unrecognizer" +"unrecognisers" = "unrecognizers" +"unrecognises" = "unrecognizes" +"unrecognising" = "unrecognizing" +"unrecognizable" = "unrecognizable" +"unrecognizably" = "unrecognizably" +"unrecognization" = "unrecognization" +"unrecognizations" = "unrecognizations" +"unrecognize" = "unrecognize" +"unrecognized" = "unrecognized" +"unrecognizer" = "unrecognizer" +"unrecognizers" = "unrecognizers" +"unrecognizes" = "unrecognizes" +"unrecognizing" = "unrecognizing" +"unsynchronisable" = "unsynchronizable" +"unsynchronisably" = "unsynchronizably" +"unsynchronisation" = "unsynchronization" +"unsynchronisations" = "unsynchronizations" +"unsynchronise" = "unsynchronize" +"unsynchronised" = "unsynchronized" +"unsynchroniser" = "unsynchronizer" +"unsynchronisers" = "unsynchronizers" +"unsynchronises" = "unsynchronizes" +"unsynchronising" = "unsynchronizing" +"unsynchronizable" = "unsynchronizable" +"unsynchronizably" = "unsynchronizably" +"unsynchronization" = "unsynchronization" +"unsynchronizations" = "unsynchronizations" +"unsynchronize" = "unsynchronize" +"unsynchronized" = "unsynchronized" +"unsynchronizer" = "unsynchronizer" +"unsynchronizers" = "unsynchronizers" +"unsynchronizes" = "unsynchronizes" +"unsynchronizing" = "unsynchronizing" +"utilisable" = "utilizable" +"utilisably" = "utilizably" +"utilisation" = "utilization" +"utilisations" = "utilizations" +"utilise" = "utilize" +"utilised" = "utilized" +"utiliser" = "utilizer" +"utilisers" = "utilizers" +"utilises" = "utilizes" +"utilising" = "utilizing" +"utilizable" = "utilizable" +"utilizably" = "utilizably" +"utilization" = "utilization" +"utilizations" = "utilizations" +"utilize" = "utilize" +"utilized" = "utilized" +"utilizer" = "utilizer" +"utilizers" = "utilizers" +"utilizes" = "utilizes" +"utilizing" = "utilizing" +"vectorisable" = "vectorizable" +"vectorisably" = "vectorizably" +"vectorisation" = "vectorization" +"vectorisations" = "vectorizations" +"vectorise" = "vectorize" +"vectorised" = "vectorized" +"vectoriser" = "vectorizer" +"vectorisers" = "vectorizers" +"vectorises" = "vectorizes" +"vectorising" = "vectorizing" +"vectorizable" = "vectorizable" +"vectorizably" = "vectorizably" +"vectorization" = "vectorization" +"vectorizations" = "vectorizations" +"vectorize" = "vectorize" +"vectorized" = "vectorized" +"vectorizer" = "vectorizer" +"vectorizers" = "vectorizers" +"vectorizes" = "vectorizes" +"vectorizing" = "vectorizing" +"virtualisable" = "virtualizable" +"virtualisably" = "virtualizably" +"virtualisation" = "virtualization" +"virtualisations" = "virtualizations" +"virtualise" = "virtualize" +"virtualised" = "virtualized" +"virtualiser" = "virtualizer" +"virtualisers" = "virtualizers" +"virtualises" = "virtualizes" +"virtualising" = "virtualizing" +"virtualizable" = "virtualizable" +"virtualizably" = "virtualizably" +"virtualization" = "virtualization" +"virtualizations" = "virtualizations" +"virtualize" = "virtualize" +"virtualized" = "virtualized" +"virtualizer" = "virtualizer" +"virtualizers" = "virtualizers" +"virtualizes" = "virtualizes" +"virtualizing" = "virtualizing" +"visualisable" = "visualizable" +"visualisably" = "visualizably" +"visualisation" = "visualization" +"visualisations" = "visualizations" +"visualise" = "visualize" +"visualised" = "visualized" +"visualiser" = "visualizer" +"visualisers" = "visualizers" +"visualises" = "visualizes" +"visualising" = "visualizing" +"visualizable" = "visualizable" +"visualizably" = "visualizably" +"visualization" = "visualization" +"visualizations" = "visualizations" +"visualize" = "visualize" +"visualized" = "visualized" +"visualizer" = "visualizer" +"visualizers" = "visualizers" +"visualizes" = "visualizes" +"visualizing" = "visualizing" +"weaponisable" = "weaponizable" +"weaponisably" = "weaponizably" +"weaponisation" = "weaponization" +"weaponisations" = "weaponizations" +"weaponise" = "weaponize" +"weaponised" = "weaponized" +"weaponiser" = "weaponizer" +"weaponisers" = "weaponizers" +"weaponises" = "weaponizes" +"weaponising" = "weaponizing" +"weaponizable" = "weaponizable" +"weaponizably" = "weaponizably" +"weaponization" = "weaponization" +"weaponizations" = "weaponizations" +"weaponize" = "weaponize" +"weaponized" = "weaponized" +"weaponizer" = "weaponizer" +"weaponizers" = "weaponizers" +"weaponizes" = "weaponizes" +"weaponizing" = "weaponizing" +"yse" = "yse" +"zeroisable" = "zeroizable" +"zeroisably" = "zeroizably" +"zeroisation" = "zeroization" +"zeroisations" = "zeroizations" +"zeroise" = "zeroize" +"zeroised" = "zeroized" +"zeroiser" = "zeroizer" +"zeroisers" = "zeroizers" +"zeroises" = "zeroizes" +"zeroising" = "zeroizing" +"zeroizable" = "zeroizable" +"zeroizably" = "zeroizably" +"zeroization" = "zeroization" +"zeroizations" = "zeroizations" +"zeroize" = "zeroize" +"zeroized" = "zeroized" +"zeroizer" = "zeroizer" +"zeroizers" = "zeroizers" +"zeroizes" = "zeroizes" +"zeroizing" = "zeroizing" From d7c53100811a2b0086b388b4c7f66bb8e68310c9 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 14 Jul 2026 00:16:18 +0200 Subject: [PATCH 2/5] Harden spelling helper interfaces Group refresh policy and transport state into immutable values so the shared spelling helper remains within the estate's code-health limits. Split refresh tests into focused modules to keep every source file below the repository's 400-line limit while preserving the full contract suite. --- Makefile | 7 +- scripts/generate_typos_config.py | 6 +- scripts/tests/conftest.py | 23 ++ scripts/tests/test_typos_rollout.py | 277 +-------------- scripts/tests/test_typos_rollout_hardening.py | 38 +- scripts/tests/test_typos_rollout_refresh.py | 336 ++++++++++++++++++ scripts/tests/typos_rollout_test_support.py | 11 + scripts/typos_rollout.py | 20 +- scripts/typos_rollout_http.py | 175 +++++---- 9 files changed, 521 insertions(+), 372 deletions(-) create mode 100644 scripts/tests/conftest.py create mode 100644 scripts/tests/test_typos_rollout_refresh.py create mode 100644 scripts/tests/typos_rollout_test_support.py diff --git a/Makefile b/Makefile index 6b147476d..38e610230 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,11 @@ SPELLING_HELPER_COVERAGE = --cov=generate_typos_config --cov=typos_rollout \ --cov=typos_rollout_cache --cov=typos_rollout_http SPELLING_HELPER_FILES = scripts/generate_typos_config.py \ scripts/typos_rollout.py scripts/typos_rollout_cache.py \ - scripts/typos_rollout_http.py scripts/tests/test_typos_rollout.py \ - scripts/tests/test_typos_rollout_hardening.py + scripts/typos_rollout_http.py scripts/tests/conftest.py \ + scripts/tests/test_typos_rollout.py \ + scripts/tests/test_typos_rollout_hardening.py \ + scripts/tests/test_typos_rollout_refresh.py \ + scripts/tests/typos_rollout_test_support.py # Markdown files, excluding build output and tool caches. CRUSH.md is a symlink # to AGENTS.md, so `-type f` skips it and avoids double-checking the same prose. MD_FILES_FIND = find . -type f -name '*.md' \ diff --git a/scripts/generate_typos_config.py b/scripts/generate_typos_config.py index 8800df82f..aba21d100 100644 --- a/scripts/generate_typos_config.py +++ b/scripts/generate_typos_config.py @@ -68,8 +68,10 @@ def main( result = rollout.refresh_base( source, repository / ".typos-oxendict-base.toml", - metadata=repository / ".typos-oxendict-base.json", - offline=offline, + rollout.RefreshOptions( + metadata=repository / ".typos-oxendict-base.json", + offline=offline, + ), ) except rollout.NetworkUnavailableError: fallback = _tracked_remote_fallback(source, destination) diff --git a/scripts/tests/conftest.py b/scripts/tests/conftest.py new file mode 100644 index 000000000..6e4539ec6 --- /dev/null +++ b/scripts/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared fixtures for spelling rollout tests.""" + +from __future__ import annotations + +import importlib +import types +from pathlib import Path + +import pytest + +SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] + + +@pytest.fixture(name="rollout_modules") +def rollout_modules_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[types.ModuleType, types.ModuleType, types.ModuleType]: + """Import scripts through the top-level paths used at runtime.""" + monkeypatch.syspath_prepend(str(SCRIPT_DIRECTORY)) + names = ("typos_rollout_cache", "typos_rollout", "generate_typos_config") + importlib.invalidate_caches() + cache, rollout, generator = (importlib.import_module(name) for name in names) + return cache, rollout, generator diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index 29d5f167d..cdf1771bf 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -3,18 +3,14 @@ from __future__ import annotations import ast -import email.message -import importlib -import json -import os import tomllib import typing as typ -import urllib.error -import urllib.request from pathlib import Path import pytest +from typos_rollout_test_support import dictionary_text as _dictionary_text + if typ.TYPE_CHECKING: import types @@ -31,28 +27,6 @@ def test_rollout_scripts_support_python_313() -> None: ) -@pytest.fixture(name="rollout_modules") -def rollout_modules_fixture( - monkeypatch: pytest.MonkeyPatch, -) -> tuple[types.ModuleType, types.ModuleType, types.ModuleType]: - """Import the scripts through the same top-level module path used at runtime.""" - monkeypatch.syspath_prepend(str(SCRIPT_DIRECTORY)) - names = ("typos_rollout_cache", "typos_rollout", "generate_typos_config") - importlib.invalidate_caches() - cache, rollout, generator = (importlib.import_module(name) for name in names) - return cache, rollout, generator - - -def _dictionary_text(stem: str = "organ") -> str: - """Return a minimal valid shared-dictionary document.""" - return ( - 'schema = 1\n\n[oxford]\nstems = ["' - + stem - + '"]\n\n[words]\naccepted = []\n\n[words.corrections]\n\n' - + "[patterns]\nignore = []\n\n[files]\nexclude = []\n" - ) - - def test_rollout_generates_oxford_corrections( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], ) -> None: @@ -69,30 +43,6 @@ def test_rollout_generates_oxford_corrections( assert italic_mappings["italicised"] == "italicized" -def test_local_refresh_keeps_a_newer_cache( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - tmp_path: Path, -) -> None: - """An older local authority cannot replace a newer untracked cache.""" - _, rollout, _ = rollout_modules - source = tmp_path / "shared.toml" - cache = tmp_path / ".typos-base.toml" - metadata = tmp_path / ".typos-base.json" - source.write_text(_dictionary_text(), encoding="utf-8") - source.touch() - rollout.refresh_base(source, cache, metadata=metadata) - cache.write_text(_dictionary_text("newer"), encoding="utf-8") - cache.touch() - source_mtime = source.stat().st_mtime_ns - cache_mtime = max(cache.stat().st_mtime_ns, source_mtime + 1) - os.utime(cache, ns=(cache_mtime, cache_mtime)) - - result = rollout.refresh_base(source, cache, metadata=metadata) - - assert result.status == "current" - assert rollout.load_dictionary(cache).stems == ("newer",) - - def test_https_failure_reuses_valid_tracked_config( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], monkeypatch: pytest.MonkeyPatch, @@ -170,226 +120,3 @@ def test_render_and_write_are_deterministic_valid_toml( assert output.read_text(encoding="utf-8") == first assert tomllib.loads(first)["default"]["locale"] == "en-gb" assert list(output.parent.glob(".typos.toml.*")) == [] - - -def test_offline_refresh_requires_and_reuses_valid_cache( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - tmp_path: Path, -) -> None: - """Offline mode fails closed before reusing a validated cache.""" - _, rollout, _ = rollout_modules - cache = tmp_path / "base.toml" - metadata = tmp_path / "base.json" - - with pytest.raises(FileNotFoundError, match="no cached shared dictionary"): - rollout.refresh_base( - "https://example.invalid/base", cache, metadata=metadata, offline=True - ) - - cache.write_text(_dictionary_text(), encoding="utf-8") - result = rollout.refresh_base( - "https://example.invalid/base", cache, metadata=metadata, offline=True - ) - - assert result.status == "offline-cache" - - -def test_local_refresh_switches_authority_and_records_metadata( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - tmp_path: Path, -) -> None: - """A different explicit authority replaces a cache regardless of mtime.""" - _, rollout, _ = rollout_modules - first = tmp_path / "first.toml" - second = tmp_path / "second.toml" - cache = tmp_path / "cache.toml" - metadata = tmp_path / "cache.json" - first.write_text(_dictionary_text("first"), encoding="utf-8") - second.write_text(_dictionary_text("second"), encoding="utf-8") - os.utime(first, ns=(3_000_000_000, 3_000_000_000)) - os.utime(second, ns=(1_000_000_000, 1_000_000_000)) - rollout.refresh_base(first, cache, metadata=metadata) - - result = rollout.refresh_base(second, cache, metadata=metadata) - - assert result.status == "refreshed" - assert rollout.load_dictionary(cache).stems == ("second",) - assert json.loads(metadata.read_text(encoding="utf-8"))["source"] == str( - second.resolve() - ) - - -def test_http_refresh_scopes_validators_and_preserves_newer_cache( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """Remote refresh reuses validators only for their original source.""" - _, rollout, _ = rollout_modules - cache = tmp_path / "cache.toml" - metadata = tmp_path / "cache.json" - requests: list[urllib.request.Request] = [] - - class Response: - """Provide the HTTP response surface consumed by the helper.""" - - status = 200 - headers: typ.ClassVar[dict[str, str]] = { - "ETag": '"estate-v1"', - "Last-Modified": "Fri, 10 Jul 2026 08:00:00 GMT", - } - - def read(self) -> bytes: - """Return a valid shared dictionary.""" - return _dictionary_text().encode() - - def __enter__(self) -> Response: - """Enter the fake response context.""" - return self - - def __exit__(self, *_args: object) -> None: - """Leave the fake response context.""" - - def open_response(request: urllib.request.Request, *, timeout: float) -> Response: - """Capture the request passed to the network boundary.""" - assert timeout == pytest.approx(30.0) - requests.append(request) - return Response() - - monkeypatch.setattr(rollout.urllib.request, "urlopen", open_response) - - first = rollout.refresh_base( - "https://example.test/base.toml", cache, metadata=metadata - ) - second = rollout.refresh_base( - "https://example.test/base.toml", cache, metadata=metadata - ) - replacement = rollout.refresh_base( - "https://example.test/replacement.toml", cache, metadata=metadata - ) - - assert first.status == "refreshed" - assert second.status == "current" - assert requests[1].get_header("If-none-match") == '"estate-v1"' - assert replacement.status == "refreshed" - assert requests[2].get_header("If-none-match") is None - assert requests[2].get_header("If-modified-since") is None - - -def test_remote_failure_reuses_only_a_valid_stale_cache( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """Network failure keeps validated data and propagates without it.""" - _, rollout, _ = rollout_modules - cache = tmp_path / "cache.toml" - metadata = tmp_path / "cache.json" - - def fail(*_args: object, **_kwargs: object) -> None: - """Model an unavailable remote authority.""" - message = "offline" - raise urllib.error.URLError(message) - - monkeypatch.setattr(rollout.urllib.request, "urlopen", fail) - - with pytest.raises(rollout.NetworkUnavailableError): - rollout.refresh_base("https://example.test/base", cache, metadata=metadata) - - cache.write_text(_dictionary_text(), encoding="utf-8") - result = rollout.refresh_base("https://example.test/base", cache, metadata=metadata) - - assert result.status == "stale-cache" - - -def test_remote_refresh_rejects_insecure_and_invalid_content( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """The remote boundary requires HTTPS and validates bytes before install.""" - _, rollout, _ = rollout_modules - cache = tmp_path / "cache.toml" - metadata = tmp_path / "cache.json" - - with pytest.raises(ValueError, match="must use HTTPS"): - rollout.refresh_base("http://example.test/base", cache, metadata=metadata) - - class InvalidResponse: - """Return malformed TOML from an otherwise successful response.""" - - status = 200 - headers: typ.ClassVar[dict[str, str]] = {} - - def read(self) -> bytes: - """Return malformed bytes.""" - return b"not = [valid" - - def __enter__(self) -> InvalidResponse: - """Enter the fake response context.""" - return self - - def __exit__(self, *_args: object) -> None: - """Leave the fake response context.""" - - monkeypatch.setattr( - rollout.urllib.request, "urlopen", lambda *_args, **_kwargs: InvalidResponse() - ) - - with pytest.raises(tomllib.TOMLDecodeError): - rollout.refresh_base("https://example.test/base", cache, metadata=metadata) - assert not cache.exists() - - -def test_metadata_reader_handles_invalid_and_non_object_json( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - tmp_path: Path, -) -> None: - """Malformed or non-object freshness metadata is safely ignored.""" - _, rollout, _ = rollout_modules - metadata = tmp_path / "cache.json" - - metadata.write_text("not-json", encoding="utf-8") - assert rollout._read_metadata(metadata) == {} - metadata.write_text("[]", encoding="utf-8") - assert rollout._read_metadata(metadata) == {} - - -def test_http_error_translation_handles_not_modified_and_stale_cache( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], - tmp_path: Path, -) -> None: - """HTTP status handling distinguishes current, stale and absent data.""" - _, rollout, _ = rollout_modules - cache = tmp_path / "cache.toml" - cache.write_text(_dictionary_text(), encoding="utf-8") - headers = email.message.Message() - not_modified = urllib.error.HTTPError( - "https://example.test/base", 304, "not modified", headers, None - ) - unavailable = urllib.error.HTTPError( - "https://example.test/base", 503, "unavailable", headers, None - ) - - assert rollout._http_error_result(cache, not_modified).status == "current" - with pytest.raises(urllib.error.HTTPError): - rollout._http_error_result(cache, unavailable) - cache.unlink() - with pytest.raises(urllib.error.HTTPError): - rollout._http_error_result(cache, unavailable) - - -def test_remote_freshness_uses_dates_and_falls_back_on_invalid_values( - rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], -) -> None: - """Last-Modified comparison remains conservative for malformed dates.""" - _, rollout, _ = rollout_modules - - assert rollout._remote_is_not_newer( - {"last_modified": "Fri, 10 Jul 2026 08:00:00 GMT"}, - {"Last-Modified": "Fri, 10 Jul 2026 07:00:00 GMT"}, - ) - assert rollout._remote_is_not_newer( - {"last_modified": "invalid"}, {"Last-Modified": "invalid"} - ) - assert not rollout._remote_is_not_newer({}, {"Last-Modified": "invalid"}) diff --git a/scripts/tests/test_typos_rollout_hardening.py b/scripts/tests/test_typos_rollout_hardening.py index 81250dd16..e3b40ecd1 100644 --- a/scripts/tests/test_typos_rollout_hardening.py +++ b/scripts/tests/test_typos_rollout_hardening.py @@ -11,17 +11,9 @@ import pytest -SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] - +from typos_rollout_test_support import dictionary_text as _dictionary_text -def _dictionary_text(stem: str = "organ") -> str: - """Return a minimal valid shared-dictionary document.""" - return ( - 'schema = 1\n\n[oxford]\nstems = ["' - + stem - + '"]\n\n[words]\naccepted = []\n\n[words.corrections]\n\n' - + "[patterns]\nignore = []\n\n[files]\nexclude = []\n" - ) +SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] class ValidResponse: @@ -120,10 +112,12 @@ def test_changed_etag_overrides_unchanged_date( result = rollout.refresh_base( source, cache, - metadata=metadata, - opener=lambda *_args, **_kwargs: ValidResponse( - stem="replacement", - headers={"ETag": '"estate-v2"', "Last-Modified": modified}, + rollout.RefreshOptions( + metadata=metadata, + opener=lambda *_args, **_kwargs: ValidResponse( + stem="replacement", + headers={"ETag": '"estate-v2"', "Last-Modified": modified}, + ), ), ) @@ -190,7 +184,9 @@ def open(self, request: object, *, timeout: float) -> ValidResponse: result = rollout.refresh_base( "https://example.test/base.toml", tmp_path / "cache.toml", - metadata=tmp_path / "cache.json", + rollout.RefreshOptions( + metadata=tmp_path / "cache.json", + ), ) assert result.status == "refreshed" @@ -228,8 +224,10 @@ def missing(*_args: object, **_kwargs: object) -> ValidResponse: rollout.refresh_base( "https://example.test/missing", cache, - metadata=metadata, - opener=missing, + rollout.RefreshOptions( + metadata=metadata, + opener=missing, + ), ) assert raised.value is not_found @@ -242,8 +240,10 @@ def denied(*_args: object, **_kwargs: object) -> None: rollout.refresh_base( "https://example.test/base", cache, - metadata=metadata, - opener=lambda *_args, **_kwargs: ValidResponse(stem="replacement"), + rollout.RefreshOptions( + metadata=metadata, + opener=lambda *_args, **_kwargs: ValidResponse(stem="replacement"), + ), ) diff --git a/scripts/tests/test_typos_rollout_refresh.py b/scripts/tests/test_typos_rollout_refresh.py new file mode 100644 index 000000000..46641044d --- /dev/null +++ b/scripts/tests/test_typos_rollout_refresh.py @@ -0,0 +1,336 @@ +"""Refresh, transport-security, and cache spelling-policy tests.""" + +from __future__ import annotations + +import email.message +import json +import os +import tomllib +import typing as typ +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +from typos_rollout_test_support import dictionary_text as _dictionary_text + +if typ.TYPE_CHECKING: + import types + + +def test_local_refresh_keeps_a_newer_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """An older local authority cannot replace a newer untracked cache.""" + _, rollout, _ = rollout_modules + source = tmp_path / "shared.toml" + cache = tmp_path / ".typos-base.toml" + metadata = tmp_path / ".typos-base.json" + source.write_text(_dictionary_text(), encoding="utf-8") + source.touch() + rollout.refresh_base( + source, + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + cache.write_text(_dictionary_text("newer"), encoding="utf-8") + cache.touch() + source_mtime = source.stat().st_mtime_ns + cache_mtime = max(cache.stat().st_mtime_ns, source_mtime + 1) + os.utime(cache, ns=(cache_mtime, cache_mtime)) + + result = rollout.refresh_base( + source, + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + assert result.status == "current" + assert rollout.load_dictionary(cache).stems == ("newer",) + + +def test_offline_refresh_requires_and_reuses_valid_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Offline mode fails closed before reusing a validated cache.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "base.toml" + metadata = tmp_path / "base.json" + + with pytest.raises(FileNotFoundError, match="no cached shared dictionary"): + rollout.refresh_base( + "https://example.invalid/base", + cache, + rollout.RefreshOptions( + metadata=metadata, + offline=True, + ), + ) + + cache.write_text(_dictionary_text(), encoding="utf-8") + result = rollout.refresh_base( + "https://example.invalid/base", + cache, + rollout.RefreshOptions( + metadata=metadata, + offline=True, + ), + ) + + assert result.status == "offline-cache" + + +def test_local_refresh_switches_authority_and_records_metadata( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """A different explicit authority replaces a cache regardless of mtime.""" + _, rollout, _ = rollout_modules + first = tmp_path / "first.toml" + second = tmp_path / "second.toml" + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + first.write_text(_dictionary_text("first"), encoding="utf-8") + second.write_text(_dictionary_text("second"), encoding="utf-8") + os.utime(first, ns=(3_000_000_000, 3_000_000_000)) + os.utime(second, ns=(1_000_000_000, 1_000_000_000)) + rollout.refresh_base( + first, + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + result = rollout.refresh_base( + second, + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + assert result.status == "refreshed" + assert rollout.load_dictionary(cache).stems == ("second",) + assert json.loads(metadata.read_text(encoding="utf-8"))["source"] == str( + second.resolve() + ) + + +def test_http_refresh_scopes_validators_and_preserves_newer_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Remote refresh reuses validators only for their original source.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + requests: list[urllib.request.Request] = [] + + class Response: + """Provide the HTTP response surface consumed by the helper.""" + + status = 200 + headers: typ.ClassVar[dict[str, str]] = { + "ETag": '"estate-v1"', + "Last-Modified": "Fri, 10 Jul 2026 08:00:00 GMT", + } + + def read(self) -> bytes: + """Return a valid shared dictionary.""" + return _dictionary_text().encode() + + def __enter__(self) -> Response: + """Enter the fake response context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the fake response context.""" + + def open_response(request: urllib.request.Request, *, timeout: float) -> Response: + """Capture the request passed to the network boundary.""" + assert timeout == pytest.approx(30.0) + requests.append(request) + return Response() + + monkeypatch.setattr(rollout.urllib.request, "urlopen", open_response) + + first = rollout.refresh_base( + "https://example.test/base.toml", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + second = rollout.refresh_base( + "https://example.test/base.toml", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + replacement = rollout.refresh_base( + "https://example.test/replacement.toml", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + assert first.status == "refreshed" + assert second.status == "current" + assert requests[1].get_header("If-none-match") == '"estate-v1"' + assert replacement.status == "refreshed" + assert requests[2].get_header("If-none-match") is None + assert requests[2].get_header("If-modified-since") is None + + +def test_remote_failure_reuses_only_a_valid_stale_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Network failure keeps validated data and propagates without it.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + + def fail(*_args: object, **_kwargs: object) -> None: + """Model an unavailable remote authority.""" + message = "offline" + raise urllib.error.URLError(message) + + monkeypatch.setattr(rollout.urllib.request, "urlopen", fail) + + with pytest.raises(rollout.NetworkUnavailableError): + rollout.refresh_base( + "https://example.test/base", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + cache.write_text(_dictionary_text(), encoding="utf-8") + result = rollout.refresh_base( + "https://example.test/base", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + assert result.status == "stale-cache" + + +def test_remote_refresh_rejects_insecure_and_invalid_content( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """The remote boundary requires HTTPS and validates bytes before install.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + metadata = tmp_path / "cache.json" + + with pytest.raises(ValueError, match="must use HTTPS"): + rollout.refresh_base( + "http://example.test/base", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + + class InvalidResponse: + """Return malformed TOML from an otherwise successful response.""" + + status = 200 + headers: typ.ClassVar[dict[str, str]] = {} + + def read(self) -> bytes: + """Return malformed bytes.""" + return b"not = [valid" + + def __enter__(self) -> InvalidResponse: + """Enter the fake response context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the fake response context.""" + + monkeypatch.setattr( + rollout.urllib.request, "urlopen", lambda *_args, **_kwargs: InvalidResponse() + ) + + with pytest.raises(tomllib.TOMLDecodeError): + rollout.refresh_base( + "https://example.test/base", + cache, + rollout.RefreshOptions( + metadata=metadata, + ), + ) + assert not cache.exists() + + +def test_metadata_reader_handles_invalid_and_non_object_json( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Malformed or non-object freshness metadata is safely ignored.""" + _, rollout, _ = rollout_modules + metadata = tmp_path / "cache.json" + + metadata.write_text("not-json", encoding="utf-8") + assert rollout._read_metadata(metadata) == {} + metadata.write_text("[]", encoding="utf-8") + assert rollout._read_metadata(metadata) == {} + + +def test_http_error_translation_handles_not_modified_and_stale_cache( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """HTTP status handling distinguishes current, stale and absent data.""" + _, rollout, _ = rollout_modules + cache = tmp_path / "cache.toml" + cache.write_text(_dictionary_text(), encoding="utf-8") + headers = email.message.Message() + not_modified = urllib.error.HTTPError( + "https://example.test/base", 304, "not modified", headers, None + ) + unavailable = urllib.error.HTTPError( + "https://example.test/base", 503, "unavailable", headers, None + ) + + assert rollout._http_error_result(cache, not_modified).status == "current" + with pytest.raises(urllib.error.HTTPError): + rollout._http_error_result(cache, unavailable) + cache.unlink() + with pytest.raises(urllib.error.HTTPError): + rollout._http_error_result(cache, unavailable) + + +def test_remote_freshness_uses_dates_and_falls_back_on_invalid_values( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], +) -> None: + """Last-Modified comparison remains conservative for malformed dates.""" + _, rollout, _ = rollout_modules + + assert rollout._remote_is_not_newer( + {"last_modified": "Fri, 10 Jul 2026 08:00:00 GMT"}, + {"Last-Modified": "Fri, 10 Jul 2026 07:00:00 GMT"}, + ) + assert rollout._remote_is_not_newer( + {"last_modified": "invalid"}, {"Last-Modified": "invalid"} + ) + assert not rollout._remote_is_not_newer({}, {"Last-Modified": "invalid"}) diff --git a/scripts/tests/typos_rollout_test_support.py b/scripts/tests/typos_rollout_test_support.py new file mode 100644 index 000000000..f2b68b3b0 --- /dev/null +++ b/scripts/tests/typos_rollout_test_support.py @@ -0,0 +1,11 @@ +"""Shared data builders for spelling rollout tests.""" + + +def dictionary_text(stem: str = "organ") -> str: + """Return a minimal valid shared-dictionary document.""" + return ( + 'schema = 1\n\n[oxford]\nstems = ["' + + stem + + '"]\n\n[words]\naccepted = []\n\n[words.corrections]\n\n' + + "[patterns]\nignore = []\n\n[files]\nexclude = []\n" + ) diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py index de9135436..587b8d3de 100644 --- a/scripts/typos_rollout.py +++ b/scripts/typos_rollout.py @@ -18,6 +18,7 @@ import collections.abc as cabc RefreshResult = typos_rollout_cache.RefreshResult +RefreshOptions = typos_rollout_http.RefreshOptions _CacheTargets = typos_rollout_cache.CacheTargets _RemoteResponse = typos_rollout_cache.RemoteResponse NetworkUnavailableError = typos_rollout_http.NetworkUnavailableError @@ -216,22 +217,19 @@ def _http_error_result( def refresh_base( source: str | pathlib.Path, cache: pathlib.Path, - *, - metadata: pathlib.Path, - offline: bool = False, - opener: typ.Callable[..., _RemoteResponse] | None = None, + options: RefreshOptions, ) -> RefreshResult: """Refresh an untracked base cache when the authoritative copy is newer.""" - selected_opener = opener - if selected_opener is None and urllib.request.urlopen is not _DEFAULT_URLOPEN: + selected_options = options + if options.opener is None and urllib.request.urlopen is not _DEFAULT_URLOPEN: # Existing focused tests patch this legacy boundary; production retains # the HTTPS-only opener when the standard function is unchanged. - selected_opener = urllib.request.urlopen + selected_options = dc.replace(options, opener=urllib.request.urlopen) return typos_rollout_http.refresh_base( source, cache, - metadata=metadata, - validate=_validate_dictionary_bytes, - offline=offline, - opener=selected_opener, + typos_rollout_http._RefreshContext( + selected_options, + _validate_dictionary_bytes, + ), ) diff --git a/scripts/typos_rollout_http.py b/scripts/typos_rollout_http.py index c4d224474..b71c26441 100644 --- a/scripts/typos_rollout_http.py +++ b/scripts/typos_rollout_http.py @@ -7,6 +7,7 @@ from __future__ import annotations +import dataclasses as dc import email.utils import json import pathlib @@ -22,6 +23,56 @@ HTTP_NOT_MODIFIED = 304 +@dc.dataclass(frozen=True, slots=True, kw_only=True) +class RefreshOptions: + """Configure one shared-dictionary refresh operation. + + Attributes + ---------- + metadata + Sidecar path containing source identity and freshness validators. + offline + Whether to reuse a valid cache without contacting the authority. + opener + Optional injectable HTTPS opener for deterministic tests. + + Examples + -------- + >>> RefreshOptions(metadata=pathlib.Path(".typos-base.json")).offline + False + """ + + metadata: pathlib.Path + offline: bool = False + opener: Opener | None = None + + +@dc.dataclass(frozen=True, slots=True) +class _LocalSourceState: + """Group local authority identity and freshness state.""" + + name: str + mtime_ns: int + validate: ContentValidator + + +@dc.dataclass(frozen=True, slots=True) +class _RefreshContext: + """Bind caller options to dictionary validation policy.""" + + options: RefreshOptions + validate: ContentValidator + + +@dc.dataclass(frozen=True, slots=True) +class _RemoteRequestState: + """Group one remote authority with its cache and saved validators.""" + + source: str + targets: typos_rollout_cache.CacheTargets + saved: cabc.Mapping[str, object] + + class NetworkUnavailableError(OSError): """Report that the remote dictionary authority could not be reached.""" @@ -86,18 +137,18 @@ def _remote_is_not_newer( def _local_cache_is_current( cache: pathlib.Path, saved: cabc.Mapping[str, object], - source_name: str, - source_mtime_ns: int, - validate: ContentValidator, + source: _LocalSourceState, ) -> bool: """Return whether metadata proves a valid local-source cache is current.""" saved_mtime = saved.get("mtime_ns") - has_matching_source = saved.get("source") == source_name + has_matching_source = saved.get("source") == source.name has_new_enough_mtime = ( - isinstance(saved_mtime, int) and source_mtime_ns <= saved_mtime + isinstance(saved_mtime, int) and source.mtime_ns <= saved_mtime ) return ( - _valid_cache(cache, validate) and has_matching_source and has_new_enough_mtime + _valid_cache(cache, source.validate) + and has_matching_source + and has_new_enough_mtime ) @@ -109,14 +160,14 @@ def _refresh_local( ) -> typos_rollout_cache.RefreshResult: """Refresh from a local authoritative copy when it is newer.""" source_stat = source.stat() - source_name = str(source.resolve()) + source_state = _LocalSourceState( + str(source.resolve()), source_stat.st_mtime_ns, validate + ) saved = _read_metadata(metadata) if _local_cache_is_current( cache, saved, - source_name, - source_stat.st_mtime_ns, - validate, + source_state, ): return typos_rollout_cache.RefreshResult("current", cache) content = source.read_bytes() @@ -124,7 +175,7 @@ def _refresh_local( typos_rollout_cache.atomic_write(cache, content) _write_metadata( metadata, - {"source": source_name, "mtime_ns": source_stat.st_mtime_ns}, + {"source": source_state.name, "mtime_ns": source_state.mtime_ns}, ) return typos_rollout_cache.RefreshResult("refreshed", cache) @@ -147,13 +198,15 @@ class _HttpsRedirectHandler(urllib.request.HTTPRedirectHandler): def redirect_request( self, request: urllib.request.Request, - file_pointer: object, - code: int, - message: str, - headers: object, - new_url: str, + *redirect: object, ) -> urllib.request.Request | None: - """Follow only redirects whose resolved target remains HTTPS.""" + """Follow only redirects whose resolved target remains HTTPS. + + The variadic tail preserves the standard library's positional override + contract without making transport-library parameters part of this + helper's domain-facing interface. + """ + file_pointer, code, message, headers, new_url = redirect if urllib.parse.urlsplit(new_url).scheme != "https": error_message = f"shared dictionary redirect must use HTTPS: {new_url}" raise InsecureSourceError(error_message) @@ -182,48 +235,46 @@ def _https_request( def _write_remote_cache( - source: str, - targets: typos_rollout_cache.CacheTargets, + state: _RemoteRequestState, content: bytes, headers: cabc.Mapping[str, str], validate: ContentValidator, ) -> typos_rollout_cache.RefreshResult: """Validate and atomically persist an HTTP dictionary response.""" validate(content) - typos_rollout_cache.atomic_write(targets.cache, content) + typos_rollout_cache.atomic_write(state.targets.cache, content) _write_metadata( - targets.metadata, + state.targets.metadata, { - "source": source, + "source": state.source, "etag": headers.get("ETag"), "last_modified": headers.get("Last-Modified"), }, ) - return typos_rollout_cache.RefreshResult("refreshed", targets.cache) + return typos_rollout_cache.RefreshResult("refreshed", state.targets.cache) def _remote_response_result( - source: str, - targets: typos_rollout_cache.CacheTargets, - saved: cabc.Mapping[str, object], + state: _RemoteRequestState, response: typos_rollout_cache.RemoteResponse, validate: ContentValidator, ) -> typos_rollout_cache.RefreshResult: """Return the cache result for a successful HTTP response.""" - if response.status == HTTP_NOT_MODIFIED and _valid_cache(targets.cache, validate): - return typos_rollout_cache.RefreshResult("current", targets.cache) - if _valid_cache(targets.cache, validate) and _remote_is_not_newer( - saved, response.headers + if response.status == HTTP_NOT_MODIFIED and _valid_cache( + state.targets.cache, validate + ): + return typos_rollout_cache.RefreshResult("current", state.targets.cache) + if _valid_cache(state.targets.cache, validate) and _remote_is_not_newer( + state.saved, response.headers ): - return typos_rollout_cache.RefreshResult("current", targets.cache) + return typos_rollout_cache.RefreshResult("current", state.targets.cache) try: content = response.read() except urllib.error.URLError as error: - message = f"shared dictionary authority is unavailable: {source}" + message = f"shared dictionary authority is unavailable: {state.source}" raise NetworkUnavailableError(message) from error return _write_remote_cache( - source, - targets, + state, content, response.headers, validate, @@ -255,45 +306,46 @@ def _http_error_result( def _refresh_http( source: str, cache: pathlib.Path, - metadata: pathlib.Path, - validate: ContentValidator, - opener: Opener | None, + context: _RefreshContext, ) -> typos_rollout_cache.RefreshResult: """Refresh a cache from a validated HTTPS source with stale fallback.""" - saved = _read_metadata(metadata) + saved = _read_metadata(context.options.metadata) if saved.get("source") != source: saved = {} request = _https_request(source, _conditional_headers(saved)) - open_remote = _HTTPS_OPENER.open if opener is None else opener + open_remote = ( + _HTTPS_OPENER.open if context.options.opener is None else context.options.opener + ) try: response_context = open_remote(request, timeout=30.0) except urllib.error.HTTPError as error: - return _http_error_result(cache, error, validate) + return _http_error_result(cache, error, context.validate) except urllib.error.URLError: message = f"shared dictionary authority is unavailable: {source}" unavailable = NetworkUnavailableError(message) - return _stale_cache_or_raise(cache, unavailable, validate) + return _stale_cache_or_raise(cache, unavailable, context.validate) with response_context as response: try: return _remote_response_result( - source, - typos_rollout_cache.CacheTargets(cache, metadata), - saved, + _RemoteRequestState( + source, + typos_rollout_cache.CacheTargets( + cache, + context.options.metadata, + ), + saved, + ), response, - validate, + context.validate, ) except NetworkUnavailableError as error: - return _stale_cache_or_raise(cache, error, validate) + return _stale_cache_or_raise(cache, error, context.validate) def refresh_base( source: str | pathlib.Path, cache: pathlib.Path, - *, - metadata: pathlib.Path, - validate: ContentValidator, - offline: bool = False, - opener: Opener | None = None, + context: _RefreshContext, ) -> typos_rollout_cache.RefreshResult: """Refresh an untracked cache when its authoritative copy is newer. @@ -303,14 +355,8 @@ def refresh_base( Local path or HTTPS URL for the authoritative shared dictionary. cache Untracked local cache destination. - metadata - Sidecar path containing source identity and freshness validators. - validate - Callback that rejects invalid dictionary bytes. - offline - Reuse a valid cache without contacting the authority. - opener - Injectable HTTPS opener. The guarded production opener is the default. + context + Refresh options bound to the dictionary validation callback. Returns ------- @@ -330,11 +376,14 @@ def refresh_base( TypeError, ValueError If dictionary validation fails. """ - if offline: - if not _valid_cache(cache, validate): + options = context.options + if options.offline: + if not _valid_cache(cache, context.validate): message = f"no cached shared dictionary at {cache}" raise FileNotFoundError(message) return typos_rollout_cache.RefreshResult("offline-cache", cache) if isinstance(source, pathlib.Path) or "://" not in str(source): - return _refresh_local(pathlib.Path(source), cache, metadata, validate) - return _refresh_http(str(source), cache, metadata, validate, opener) + return _refresh_local( + pathlib.Path(source), cache, options.metadata, context.validate + ) + return _refresh_http(str(source), cache, context) From c5151993fd2ca0a342aa638360777aa703d2ddd1 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 14 Jul 2026 02:33:31 +0200 Subject: [PATCH 3/5] Complete Oxford spelling enforcement Refresh the rollout from the merged shared authority, enforce exact phrase corrections alongside Typos, and replace maintained artefact spellings while preserving public workflow contracts with narrow protections. Apply the outstanding review fixes, split and document the helper tests, cache shared dictionary state in CI, and retain the documented focused-test alias. Rename the legacy package helper to its Oxford spelling and update all internal references. --- .github/workflows/build-and-package.yml | 6 +- .github/workflows/ci.yml | 9 + .github/workflows/release.yml | 14 +- Makefile | 15 +- docs/developers-guide.md | 11 +- ...-10-1-guarantee-status-message-ordering.md | 2 +- ...config-struct-derived-with-ortho-config.md | 2 +- ...3-expose-config-path-and-netsuke-config.md | 2 +- docs/execplans/3-12-1-define-design-tokens.md | 2 +- ...t-progress-and-status-output-for-themes.md | 2 +- ...available-non-throwing-executable-probe.md | 2 +- ...-graph-subcommand-with-an-html-renderer.md | 2 +- ...ult-subcommand-builds-manifest-defaults.md | 2 +- .../3-7-1-externalize-user-facing-strings.md | 2 +- .../3-8-1-add-accessible-output-mode.md | 2 +- ...-1-kani-tooling-and-local-smoke-targets.md | 2 +- ...nesses-for-manifest-to-ir-safety-checks.md | 2 +- docs/execplans/cargo-orthohelp-adoption.md | 6 +- ...-bdd-v0-5-0-behavioural-suite-migration.md | 4 +- docs/netsuke-design.md | 2 +- scripts/generate_typos_config.py | 51 ++++- ...ackage-artifact.sh => package-artefact.sh} | 2 +- scripts/tests/test_typos_rollout.py | 92 ++++++-- scripts/tests/test_typos_rollout_check.py | 87 +++++++ scripts/typos_rollout.py | 141 +++++++++++- scripts/typos_rollout_cache.py | 15 +- scripts/typos_rollout_check.py | 215 ++++++++++++++++++ tests/features/progress_output.feature | 2 +- tests/logging_stderr_tests.rs | 2 +- tests/workflow_build_and_package.rs | 8 +- typos.local.toml | 25 +- typos.toml | 24 +- 32 files changed, 673 insertions(+), 82 deletions(-) rename scripts/{package-artifact.sh => package-artefact.sh} (94%) create mode 100644 scripts/tests/test_typos_rollout_check.py create mode 100644 scripts/typos_rollout_check.py diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml index d0882c4a3..c10f923b7 100644 --- a/.github/workflows/build-and-package.yml +++ b/.github/workflows/build-and-package.yml @@ -124,7 +124,7 @@ jobs: echo "binary_path=${{ steps.stage.outputs['binary-path'] }}" echo "man_path=${{ steps.stage.outputs['man-path'] }}" echo "license_path=${{ steps.stage.outputs['license-path'] }}" - echo "artifact_dir=${{ steps.stage.outputs['artifact-dir'] }}" + echo "artefact_dir=${{ steps.stage.outputs['artifact-dir'] }}" echo "powershell_help_dir=${{ steps.stage.outputs.powershell_help_dir }}" } >>"$GITHUB_OUTPUT" @@ -206,7 +206,7 @@ jobs: with: name: ${{ inputs['artifact-name'] }} path: | - ${{ steps.stage_paths.outputs.artifact_dir }} + ${{ steps.stage_paths.outputs.artefact_dir }} ${{ steps.package_windows.outputs['msi-path'] }} - name: Upload macOS artefacts @@ -216,5 +216,5 @@ jobs: with: name: ${{ inputs['artifact-name'] }} path: | - ${{ steps.stage_paths.outputs.artifact_dir }} + ${{ steps.stage_paths.outputs.artefact_dir }} dist/${{ inputs['bin-name'] }}-${{ inputs.version }}-${{ inputs['artifact-suffix'] }}.pkg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0acf8f4cd..93cb55959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,15 @@ jobs: with: python-version: '3.13' enable-cache: true + - name: Cache shared spelling dictionary + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + .typos-oxendict-base.json + .typos-oxendict-base.toml + key: oxendict-${{ runner.os }}-${{ matrix.rust }}-${{ hashFiles('typos.local.toml') }}-${{ github.run_id }} + restore-keys: | + oxendict-${{ runner.os }}-${{ matrix.rust }}-${{ hashFiles('typos.local.toml') }}- - name: Spelling run: make spelling - name: Workflow contract tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index caed8819c..df06c22bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,11 +124,11 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - artifact: linux-amd64 + artefact: linux-amd64 package_arch: amd64 target_key: linux-x86_64 - target: aarch64-unknown-linux-gnu - artifact: linux-arm64 + artefact: linux-arm64 package_arch: arm64 target_key: linux-aarch64 uses: ./.github/workflows/build-and-package.yml @@ -138,7 +138,7 @@ jobs: target: ${{ matrix.target }} bin-name: ${{ needs.metadata.outputs.bin_name }} version: ${{ needs.metadata.outputs.version }} - artifact-name: ${{ needs.metadata.outputs.repo_name }}-${{ matrix.artifact }} + artifact-name: ${{ needs.metadata.outputs.repo_name }}-${{ matrix.artefact }} should-upload-workflow-artifacts: ${{ fromJSON(needs.metadata.outputs.should_upload_workflow_artifacts) }} package-arch: ${{ matrix.package_arch }} stage-target: ${{ matrix.target_key }} @@ -186,11 +186,11 @@ jobs: include: - target: x86_64-apple-darwin runner: macos-15-intel - artifact_suffix: macos-x86_64 + artefact_suffix: macos-x86_64 target_key: macos-x86_64 - target: aarch64-apple-darwin runner: macos-15 - artifact_suffix: macos-arm64 + artefact_suffix: macos-arm64 target_key: macos-aarch64 uses: ./.github/workflows/build-and-package.yml with: @@ -199,10 +199,10 @@ jobs: target: ${{ matrix.target }} bin-name: ${{ needs.metadata.outputs.bin_name }} version: ${{ needs.metadata.outputs.version }} - artifact-name: ${{ needs.metadata.outputs.repo_name }}-${{ matrix.artifact_suffix }} + artifact-name: ${{ needs.metadata.outputs.repo_name }}-${{ matrix.artefact_suffix }} should-upload-workflow-artifacts: ${{ fromJSON(needs.metadata.outputs.should_upload_workflow_artifacts) }} stage-target: ${{ matrix.target_key }} - artifact-suffix: ${{ matrix.artifact_suffix }} + artifact-suffix: ${{ matrix.artefact_suffix }} release: if: needs.metadata.outputs.should_publish == 'true' diff --git a/Makefile b/Makefile index 38e610230..939267dd3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all clean test test-workflow-contracts test-typos-config build release lint lint-clippy lint-whitaker fmt check-fmt typecheck markdownlint spelling spelling-helper-test nixie install-kani kani-check kani-full kani-ir install-verus verus formal-pr +.PHONY: help all clean test test-workflow-contracts test-typos-config build release lint lint-clippy lint-whitaker fmt check-fmt typecheck markdownlint spelling spelling-config spelling-helper-test nixie install-kani kani-check kani-full kani-ir install-verus verus formal-pr APP ?= netsuke CARGO ?= $(shell command -v cargo 2>/dev/null || printf '%s' "$$HOME/.cargo/bin/cargo") @@ -17,12 +17,14 @@ TYPOS_VERSION ?= 1.48.0 UV ?= uv UV_ENV = UV_CACHE_DIR=.uv-cache UV_TOOL_DIR=.uv-tools RUFF_VERSION ?= 0.15.12 -SPELLING_HELPER_COVERAGE = --cov=generate_typos_config --cov=typos_rollout \ +SPELLING_HELPER_COVERAGE = --cov=generate_typos_config --cov=typos_rollout_check --cov=typos_rollout \ --cov=typos_rollout_cache --cov=typos_rollout_http SPELLING_HELPER_FILES = scripts/generate_typos_config.py \ + scripts/typos_rollout_check.py \ scripts/typos_rollout.py scripts/typos_rollout_cache.py \ scripts/typos_rollout_http.py scripts/tests/conftest.py \ scripts/tests/test_typos_rollout.py \ + scripts/tests/test_typos_rollout_check.py \ scripts/tests/test_typos_rollout_hardening.py \ scripts/tests/test_typos_rollout_refresh.py \ scripts/tests/typos_rollout_test_support.py @@ -82,12 +84,15 @@ typecheck: ## Typecheck all targets and features markdownlint: spelling ## Lint Markdown and enforce en-GB-oxendict spelling $(MDLINT) "**/*.md" -spelling: spelling-helper-test ## Enforce en-GB-oxendict spelling in Markdown prose +spelling: spelling-config ## Enforce en-GB-oxendict spelling in Markdown prose + @PYTHONPATH=scripts $(UV_ENV) $(UV) run --no-project --python 3.13 scripts/typos_rollout_check.py --repository . + @$(MD_FILES_FIND) | xargs -0 -r env $(UV_ENV) \ + $(UV) tool run typos@$(TYPOS_VERSION) --config typos.toml --force-exclude + +spelling-config: spelling-helper-test ## Generate and validate the spelling configuration @$(UV_ENV) $(UV) run scripts/generate_typos_config.py @git ls-files --error-unmatch typos.toml >/dev/null @git diff --exit-code -- typos.toml - @$(MD_FILES_FIND) | xargs -0 -r env $(UV_ENV) \ - $(UV) tool run typos@$(TYPOS_VERSION) --config typos.toml --force-exclude spelling-helper-test: ## Validate the shared spelling-policy integration @$(UV_ENV) $(UV) tool run ruff@$(RUFF_VERSION) format --isolated --target-version py313 --check $(SPELLING_HELPER_FILES) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 7d62dddf1..a69ecf787 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -114,21 +114,26 @@ For documentation changes, also run: `make markdownlint` enforces en-GB-oxendict (Oxford) spelling over the repository's Markdown prose with [`typos`](https://github.com/crate-ci/typos), as required by the [documentation style guide](documentation-style-guide.md). -The repository-root `typos.toml` is deterministic generated output assembled +The repository-root `typos.toml` is deterministically generated output assembled from two policy layers: 1. The shared estate dictionary in `leynos/agent-helper-scripts` supplies - generally valid Oxford forms, accepted technical terms, corrections and + generally valid Oxford forms, accepted technical terms, corrections, and exclusions. The generator conditionally refreshes this authority into an untracked local cache and reuses a valid cache when working offline. 2. `typos.local.toml` contains only Netsuke-specific names, identifiers, - fixtures and exclusions. It cannot replace a conflicting shared correction. + fixtures, and exclusions. It cannot replace a conflicting shared correction. `scripts/typos_rollout_http.py` owns shared-cache freshness, HTTPS transport security and persistence coordination. Only `scripts/typos_rollout.py` may compose it with dictionary validation; application and release code must not reuse these spelling-policy internals. +Pull-request CI restores the untracked dictionary and metadata before the +spelling gate. The helper still performs a conditional freshness check, then +saves refreshed state for later runs; a transient outage can therefore reuse a +validated stale cache. + The generated policy sets the `en-gb` locale to correct American spellings (`color` to `colour`, `behavior` to `behaviour`, `analyzed` to `analysed`). It also restores Oxford spelling through generated entries that accept `-ize` diff --git a/docs/execplans/3-10-1-guarantee-status-message-ordering.md b/docs/execplans/3-10-1-guarantee-status-message-ordering.md index e2274aadc..ed566706d 100644 --- a/docs/execplans/3-10-1-guarantee-status-message-ordering.md +++ b/docs/execplans/3-10-1-guarantee-status-message-ordering.md @@ -410,7 +410,7 @@ If a stage fails: 3. If within tolerances, fix and retry. 4. If tolerances exceeded, escalate with documented context. -## Artifacts and notes +## Artefacts and notes Key code locations: diff --git a/docs/execplans/3-11-1-cli-config-struct-derived-with-ortho-config.md b/docs/execplans/3-11-1-cli-config-struct-derived-with-ortho-config.md index b0afc00e1..22082e6a7 100644 --- a/docs/execplans/3-11-1-cli-config-struct-derived-with-ortho-config.md +++ b/docs/execplans/3-11-1-cli-config-struct-derived-with-ortho-config.md @@ -719,7 +719,7 @@ If a stage fails halfway: - If BDD tests are not picked up after feature-file edits, run `touch tests/bdd_tests.rs` before `make test`. -## Artifacts and notes +## Artefacts and notes Expected new files: diff --git a/docs/execplans/3-11-3-expose-config-path-and-netsuke-config.md b/docs/execplans/3-11-3-expose-config-path-and-netsuke-config.md index 232b284e6..a3bb5c50a 100644 --- a/docs/execplans/3-11-3-expose-config-path-and-netsuke-config.md +++ b/docs/execplans/3-11-3-expose-config-path-and-netsuke-config.md @@ -764,7 +764,7 @@ If a stage fails partway through, fix the issue and re-run from the start of that stage. No rollback is needed because no destructive operations are performed. -## Artifacts and notes +## Artefacts and notes ### File change summary (expected) diff --git a/docs/execplans/3-12-1-define-design-tokens.md b/docs/execplans/3-12-1-define-design-tokens.md index 56af5167c..0916fe06a 100644 --- a/docs/execplans/3-12-1-define-design-tokens.md +++ b/docs/execplans/3-12-1-define-design-tokens.md @@ -577,7 +577,7 @@ The planned edits are safe to repeat. - If the implementation breaches a tolerance, stop, update `Decision Log`, and wait for explicit direction rather than improvising a broader redesign. -## Artifacts and notes +## Artefacts and notes Useful evidence to keep while implementing: diff --git a/docs/execplans/3-12-2-snapshot-progress-and-status-output-for-themes.md b/docs/execplans/3-12-2-snapshot-progress-and-status-output-for-themes.md index f3dce4c70..46130033a 100644 --- a/docs/execplans/3-12-2-snapshot-progress-and-status-output-for-themes.md +++ b/docs/execplans/3-12-2-snapshot-progress-and-status-output-for-themes.md @@ -679,7 +679,7 @@ The planned edits are safe to repeat. - If `make fmt` rewrites unrelated Markdown files, inspect `git diff` and restore only formatter-introduced changes outside the 3.12.2 scope. -## Artifacts and notes +## Artefacts and notes Useful evidence to keep while implementing: diff --git a/docs/execplans/3-14-4-command-available-non-throwing-executable-probe.md b/docs/execplans/3-14-4-command-available-non-throwing-executable-probe.md index 6076828a2..1d01a8a9b 100644 --- a/docs/execplans/3-14-4-command-available-non-throwing-executable-probe.md +++ b/docs/execplans/3-14-4-command-available-non-throwing-executable-probe.md @@ -741,7 +741,7 @@ pub(super) fn is_command_available( No public Rust API outside `src/stdlib/which/` changes. -## Artifacts and notes +## Artefacts and notes Reference artefacts from the 3.14.2 work, all of which can be inspected with `leta show`: diff --git a/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md b/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md index b25b74806..9ece96fa0 100644 --- a/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md +++ b/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md @@ -1020,7 +1020,7 @@ Quality criteria for the final commit: if a stage produces partial work, push the work-in-progress to the remote and reopen the draft PR for review before continuing. -## Artifacts and notes +## Artefacts and notes Sample HTML structure (illustrative; final wording is localized): diff --git a/docs/execplans/3-6-1-ensure-default-subcommand-builds-manifest-defaults.md b/docs/execplans/3-6-1-ensure-default-subcommand-builds-manifest-defaults.md index 4b3692fad..87fbed16b 100644 --- a/docs/execplans/3-6-1-ensure-default-subcommand-builds-manifest-defaults.md +++ b/docs/execplans/3-6-1-ensure-default-subcommand-builds-manifest-defaults.md @@ -330,7 +330,7 @@ All commands are run from the repository root (`/root/repo`). Use `tee` with - If localization keys conflict with existing entries, rename them with a unique prefix. -## Artifacts and notes +## Artefacts and notes Keep the following transcripts for evidence: diff --git a/docs/execplans/3-7-1-externalize-user-facing-strings.md b/docs/execplans/3-7-1-externalize-user-facing-strings.md index 4511ad04c..039eb0f3d 100644 --- a/docs/execplans/3-7-1-externalize-user-facing-strings.md +++ b/docs/execplans/3-7-1-externalize-user-facing-strings.md @@ -237,7 +237,7 @@ Quality criteria (done means all of these are true): - If a gate fails, fix the underlying issue and re-run the same command, overwriting the log file to keep evidence current. -## Artifacts and notes +## Artefacts and notes Keep these logs as evidence of success: diff --git a/docs/execplans/3-8-1-add-accessible-output-mode.md b/docs/execplans/3-8-1-add-accessible-output-mode.md index bf4b829fc..bdb986550 100644 --- a/docs/execplans/3-8-1-add-accessible-output-mode.md +++ b/docs/execplans/3-8-1-add-accessible-output-mode.md @@ -604,7 +604,7 @@ adds animated output. If a step fails partway through, fix the issue and re-run the validation command. No rollback is needed beyond `git checkout` of the affected files. -## Artifacts and notes +## Artefacts and notes Key files created: diff --git a/docs/execplans/4-1-1-kani-tooling-and-local-smoke-targets.md b/docs/execplans/4-1-1-kani-tooling-and-local-smoke-targets.md index d052ebe9a..72f2cf4d5 100644 --- a/docs/execplans/4-1-1-kani-tooling-and-local-smoke-targets.md +++ b/docs/execplans/4-1-1-kani-tooling-and-local-smoke-targets.md @@ -593,7 +593,7 @@ rm -rf tools/kani scripts/install-kani.sh scripts/check-kani-version.sh Do not use destructive rollback commands after user or other-agent edits appear in the same files; inspect `git diff` first and preserve unrelated changes. -## Artifacts and notes +## Artefacts and notes Firecrawl research confirmed these external facts: diff --git a/docs/execplans/4-2-1-kani-harnesses-for-manifest-to-ir-safety-checks.md b/docs/execplans/4-2-1-kani-harnesses-for-manifest-to-ir-safety-checks.md index 23d825182..453b9547c 100644 --- a/docs/execplans/4-2-1-kani-harnesses-for-manifest-to-ir-safety-checks.md +++ b/docs/execplans/4-2-1-kani-harnesses-for-manifest-to-ir-safety-checks.md @@ -1252,7 +1252,7 @@ subtasks remain independent. If `make kani-full` regresses, narrow with `cargo kani --harness ...` to identify the offending harness, and either reduce its bound or add a `#[kani::unwind(N)]` override. -## Artifacts and notes +## Artefacts and notes Captured `/tmp` logs from Stage B onward must be referenced in the PR description when summarizing validation evidence. Each command's log lives at diff --git a/docs/execplans/cargo-orthohelp-adoption.md b/docs/execplans/cargo-orthohelp-adoption.md index 097328bb7..9ca548269 100644 --- a/docs/execplans/cargo-orthohelp-adoption.md +++ b/docs/execplans/cargo-orthohelp-adoption.md @@ -329,7 +329,7 @@ The relevant release and documentation files are: - `.github/release-staging.toml`: declares staged artefact sources. It currently finds the manual page under `target/generated-man` or Cargo's `OUT_DIR`. -- `scripts/package-artifact.sh`: legacy local packaging helper that still +- `scripts/package-artefact.sh`: legacy local packaging helper that still expects `target/generated-man//release/.1`. - `tests/workflow_build_and_package.rs`, `tests/workflow_release.rs`, and `tests/workflow_shared_actions_pins.rs`: @@ -435,7 +435,7 @@ fallback, and add Windows PowerShell artefact entries for: If the staging helper cannot mark those files as Windows-only, keep them `required = false` so Linux and macOS staging do not fail. -Update `scripts/package-artifact.sh` to read the man page from +Update `scripts/package-artefact.sh` to read the man page from `target/orthohelp//release/man/man1/.1`, or remove it only if a separate approved decision confirms the script is obsolete. @@ -575,7 +575,7 @@ transient network issue, rerun it once. If it fails because of a compiler or dependency incompatibility, stop and record the error in `Decision Log` before choosing a different installation strategy. -## Artifacts and notes +## Artefacts and notes Reconnaissance evidence gathered before drafting: diff --git a/docs/execplans/rstest-bdd-v0-5-0-behavioural-suite-migration.md b/docs/execplans/rstest-bdd-v0-5-0-behavioural-suite-migration.md index 0c88d8284..4fdbbe3c3 100644 --- a/docs/execplans/rstest-bdd-v0-5-0-behavioural-suite-migration.md +++ b/docs/execplans/rstest-bdd-v0-5-0-behavioural-suite-migration.md @@ -102,7 +102,7 @@ the conflict in `Decision Log` before proceeding. of truth for testing strategy. - Observation: behavioural tests are discovered via `scenarios!` in - `tests/bdd_tests.rs`; there are currently no hand-written `#[scenario]` + `tests/bdd_tests.rs`; there are currently no `hand-written` `#[scenario]` functions in the suite entry point. Evidence: `tests/bdd_tests.rs` contains `scenarios!("tests/features", ...)` and `scenarios!("tests/features_unix", ...)`. Impact: scenario return-type @@ -336,7 +336,7 @@ The migration is complete when all of the following are true: - Keep changes staged by domain module so partial rollbacks are small and deterministic. -## Artifacts and notes +## Artefacts and notes Collect and keep short excerpts from: diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 240b69d71..9d187b626 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1085,7 +1085,7 @@ for transforming data within templates. - `| compact`: A planned collection filter that removes empty strings and null values while preserving order. It supports patterns such as constructing - `RUSTFLAGS` from an optional user override without hand-written shell tests. + `RUSTFLAGS` from an optional user override without `hand-written` shell tests. - `| to_path`: A filter that converts a string into a platform-native path representation, handling `/` and `\` separators correctly. diff --git a/scripts/generate_typos_config.py b/scripts/generate_typos_config.py index aba21d100..a48c869af 100644 --- a/scripts/generate_typos_config.py +++ b/scripts/generate_typos_config.py @@ -25,7 +25,18 @@ def dictionary_from_cache(repository: Path = REPOSITORY_ROOT) -> rollout.Dictionary: - """Load the cached shared base merged with local repository policy.""" + """Load the cached shared base merged with local repository policy. + + Parameters + ---------- + repository + Repository root containing the shared cache and local overlay. + + Returns + ------- + rollout.Dictionary + Shared policy merged with ``typos.local.toml`` when it exists. + """ dictionary = rollout.load_dictionary(repository / ".typos-oxendict-base.toml") local_overlay = repository / "typos.local.toml" if local_overlay.exists(): @@ -37,7 +48,18 @@ def dictionary_from_cache(repository: Path = REPOSITORY_ROOT) -> rollout.Diction def render_config(repository: Path = REPOSITORY_ROOT) -> str: - """Render deterministic configuration from the populated local cache.""" + """Render deterministic configuration from the populated local cache. + + Parameters + ---------- + repository + Repository root containing the shared cache and local overlay. + + Returns + ------- + str + Rendered ``typos.toml`` content. + """ return rollout.render_typos_config(dictionary_from_cache(repository)) @@ -62,7 +84,30 @@ def main( source: str | Path = DEFAULT_BASE_URL, offline: bool = False, ) -> rollout.RefreshResult: - """Refresh the shared base cache and write the merged configuration.""" + """Refresh the shared base cache and write the merged configuration. + + Parameters + ---------- + output + Destination for generated configuration. By default, write + ``repository / "typos.toml"``. + repository + Repository root containing caches, local policy, and output. + source + Local path or HTTPS URL for the authoritative shared dictionary. + offline + Whether to reuse a valid cache without contacting the authority. + + Returns + ------- + rollout.RefreshResult + Refresh status and cache path, including tracked-config fallback. + + Raises + ------ + rollout.NetworkUnavailableError + If the authority is unavailable and no tracked fallback is valid. + """ destination = output if output is not None else repository / "typos.toml" try: result = rollout.refresh_base( diff --git a/scripts/package-artifact.sh b/scripts/package-artefact.sh similarity index 94% rename from scripts/package-artifact.sh rename to scripts/package-artefact.sh index 0f84a2121..54ced12fd 100755 --- a/scripts/package-artifact.sh +++ b/scripts/package-artefact.sh @@ -18,7 +18,7 @@ require_file() { fi } -# package-artifact.sh +# package-artefact.sh # Copies the built binary and generated man page into an OS/arch-specific # directory and writes SHA256 checksums for each file. diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index cdf1771bf..2556459f2 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -35,12 +35,14 @@ def test_rollout_generates_oxford_corrections( mappings = rollout.generate_word_mappings(rollout.Dictionary(stems=("organ",))) - assert mappings["organize"] == "organize" - assert mappings["organise"] == "organize" + assert mappings["organize"] == "organize", "Oxford form was not accepted" + assert mappings["organise"] == "organize", "plain-British form was not fixed" italic_mappings = rollout.generate_word_mappings( rollout.Dictionary(stems=("italic",)) ) - assert italic_mappings["italicised"] == "italicized" + assert italic_mappings["italicised"] == "italicized", ( + "plain-British italic form was not fixed" + ) def test_https_failure_reuses_valid_tracked_config( @@ -61,42 +63,78 @@ def unavailable(*_args: object, **_kwargs: object) -> None: result = generator.main(repository=tmp_path, source="https://example.invalid/base") - assert result.status == "tracked-config" - assert result.cache == tracked_config + assert result.status == "tracked-config", "tracked fallback was not selected" + assert result.cache == tracked_config, "fallback did not return tracked config" +@pytest.mark.parametrize( + "document", + [ + pytest.param( + _dictionary_text().replace("schema = 1", "schema = 2"), + id="unsupported-schema", + ), + pytest.param( + _dictionary_text().replace( + '[oxford]\nstems = ["organ"]', + 'oxford = "bad"', + ), + id="invalid-table", + ), + pytest.param( + _dictionary_text().replace('stems = ["organ"]', "stems = [1]"), + id="invalid-string-list", + ), + pytest.param( + _dictionary_text().replace( + "[words.corrections]", + "[words.corrections]\nteh = 1", + ), + id="invalid-correction", + ), + ], +) def test_dictionary_validation_rejects_invalid_documents( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], tmp_path: Path, + document: str, ) -> None: """Schema, table, string-list and correction types remain validated.""" _, rollout, _ = rollout_modules source = tmp_path / "base.toml" - invalid_documents = ( - _dictionary_text().replace("schema = 1", "schema = 2"), - _dictionary_text().replace('[oxford]\nstems = ["organ"]', 'oxford = "bad"'), - _dictionary_text().replace('stems = ["organ"]', "stems = [1]"), - _dictionary_text().replace( - "[words.corrections]", "[words.corrections]\nteh = 1" - ), - ) + source.write_text(document, encoding="utf-8") - for document in invalid_documents: - source.write_text(document, encoding="utf-8") - with pytest.raises((TypeError, ValueError)): - rollout.load_dictionary(source) + with pytest.raises((TypeError, ValueError)): + rollout.load_dictionary(source) def test_merge_rejects_conflicting_corrections( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], ) -> None: - """A local overlay cannot silently weaken a shared correction.""" + """A local overlay cannot silently weaken shared word or phrase policy.""" _, rollout, _ = rollout_modules - base = rollout.Dictionary(corrections=(("teh", "the"),)) - local = rollout.Dictionary(corrections=(("teh", "ten"),)) + prohibited_phrase = "hand" + "-written" + base = rollout.Dictionary( + corrections=(("teh", "the"),), + phrase_corrections=((prohibited_phrase, "handwritten"),), + ) + + merged = rollout.merge_dictionaries(base, rollout.Dictionary()) + + assert merged.phrase_corrections == base.phrase_corrections, ( + "an empty local policy discarded the shared phrase correction" + ) with pytest.raises(ValueError, match="conflicting correction"): - rollout.merge_dictionaries(base, local) + rollout.merge_dictionaries( + base, + rollout.Dictionary(corrections=(("teh", "ten"),)), + ) + with pytest.raises(ValueError, match="conflicting phrase correction"): + rollout.merge_dictionaries( + base, + rollout.Dictionary(phrase_corrections=((prohibited_phrase, "other"),)), + ) def test_render_and_write_are_deterministic_valid_toml( @@ -116,7 +154,11 @@ def test_render_and_write_are_deterministic_valid_toml( first = rollout.render_typos_config(dictionary) rollout.write_config(output, dictionary) - assert first == rollout.render_typos_config(dictionary) - assert output.read_text(encoding="utf-8") == first - assert tomllib.loads(first)["default"]["locale"] == "en-gb" - assert list(output.parent.glob(".typos.toml.*")) == [] + assert first == rollout.render_typos_config(dictionary), "rendering was unstable" + assert output.read_text(encoding="utf-8") == first, "written config changed" + assert tomllib.loads(first)["default"]["locale"] == "en-gb", ( + "rendered locale was not en-gb" + ) + assert list(output.parent.glob(".typos.toml.*")) == [], ( + "atomic write left a temporary file" + ) diff --git a/scripts/tests/test_typos_rollout_check.py b/scripts/tests/test_typos_rollout_check.py new file mode 100644 index 000000000..22d6dd5a9 --- /dev/null +++ b/scripts/tests/test_typos_rollout_check.py @@ -0,0 +1,87 @@ +"""Test exact phrase-policy enforcement.""" + +import importlib +from pathlib import Path +import subprocess +import types + +import pytest + + +SCRIPTS = Path(__file__).resolve().parents[1] +PROHIBITED = "hand" + "-written" +TITLE_PROHIBITED = "Hand" + "-written" + + +@pytest.fixture(name="modules") +def modules_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> tuple[types.ModuleType, types.ModuleType]: + """Load the rollout and phrase-checker modules from the scripts directory.""" + monkeypatch.syspath_prepend(str(SCRIPTS)) + importlib.invalidate_caches() + return ( + importlib.import_module("typos_rollout"), + importlib.import_module("typos_rollout_check"), + ) + + +def initialize(path: Path, files: dict[str, str]) -> None: + """Create and stage a minimal repository containing the supplied files.""" + for relative, content in files.items(): + target = path / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + subprocess.run(["git", "init", "--quiet"], cwd=path, check=True) + subprocess.run(["git", "add", "."], cwd=path, check=True) + + +def test_checker_boundaries_ignores_exclusions( + modules: tuple[types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """The checker honours token boundaries, ignored spans, and exclusions.""" + rollout, check = modules + initialize( + tmp_path, + { + "README.md": (f"{PROHIBITED}\n{TITLE_PROHIBITED} prose\n`{PROHIBITED}`\n"), + "skip.md": f"{PROHIBITED}\n", + "joined.md": "pre-hand" + "-written\n", + }, + ) + policy = rollout.Dictionary( + phrase_corrections=((PROHIBITED, "handwritten"),), + ignore_patterns=(r"`[^`\n]+`",), + excluded_files=("skip.md",), + ) + + actual = [ + (finding.line, finding.phrase) + for finding in check.check_phrase_corrections(tmp_path, policy) + ] + expected = [(1, PROHIBITED), (2, TITLE_PROHIBITED)] + + assert actual == expected, "phrase boundaries or policy exclusions changed" + + +def test_main_reports( + modules: tuple[types.ModuleType, types.ModuleType], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The command reports source positions and returns a failure status.""" + rollout, check = modules + initialize(tmp_path, {"README.md": f"Prefer {PROHIBITED}.\n"}) + monkeypatch.setattr( + check.generator, + "dictionary_from_cache", + lambda _: rollout.Dictionary(phrase_corrections=((PROHIBITED, "handwritten"),)), + ) + + status = check.main(["--repository", str(tmp_path)]) + output = capsys.readouterr().out + + assert status == 2, "the command accepted a prohibited phrase" + assert "README.md:1:8:" in output, "the diagnostic omitted its source position" diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py index 587b8d3de..5ba533cc9 100644 --- a/scripts/typos_rollout.py +++ b/scripts/typos_rollout.py @@ -53,6 +53,7 @@ class Dictionary: stems: tuple[str, ...] = () accepted: tuple[str, ...] = () corrections: tuple[tuple[str, str], ...] = () + phrase_corrections: tuple[tuple[str, str], ...] = () ignore_patterns: tuple[str, ...] = () excluded_files: tuple[str, ...] = () @@ -84,6 +85,7 @@ def _dictionary_from_text(text: str) -> Dictionary: raise ValueError(message) oxford = _table(document, "oxford") words = _table(document, "words") + phrases = _table(document, "phrases") patterns = _table(document, "patterns") files = _table(document, "files") corrections_table = _table(words, "corrections") @@ -94,22 +96,66 @@ def _dictionary_from_text(text: str) -> Dictionary: message = "word corrections must map strings to strings" raise TypeError(message) corrections = typ.cast("cabc.Mapping[str, str]", corrections_table) + phrase_table = _table(phrases, "corrections") + if not all( + isinstance(k, str) and isinstance(v, str) for k, v in phrase_table.items() + ): + message = "phrase corrections must map strings to strings" + raise TypeError(message) + phrase_corrections = typ.cast("cabc.Mapping[str, str]", phrase_table) return Dictionary( stems=_string_list(oxford, "stems"), accepted=_string_list(words, "accepted"), corrections=tuple(sorted(corrections.items())), + phrase_corrections=tuple(sorted(phrase_corrections.items())), ignore_patterns=_string_list(patterns, "ignore"), excluded_files=_string_list(files, "exclude"), ) def load_dictionary(path: pathlib.Path) -> Dictionary: - """Load a validated shared dictionary from *path*.""" + """Load a validated shared dictionary from a path. + + Parameters + ---------- + path + UTF-8 TOML dictionary to load. + + Returns + ------- + Dictionary + Validated spelling policy. + + Raises + ------ + OSError + If the dictionary cannot be read. + TypeError, ValueError + If the document does not satisfy the shared dictionary schema. + """ return _dictionary_from_text(path.read_text(encoding="utf-8")) def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: - """Merge a shared dictionary with a non-conflicting local overlay.""" + """Merge a shared dictionary with a non-conflicting local overlay. + + Parameters + ---------- + base + Estate-wide spelling policy. + local + Repository-specific policy to add. + + Returns + ------- + Dictionary + Deterministically merged policy. + + Raises + ------ + ValueError + If the local policy conflicts with a shared correction. + """ corrections = dict(base.corrections) for word, correction in local.corrections: existing = corrections.get(word) @@ -119,10 +165,18 @@ def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: ) raise ValueError(message) corrections[word] = correction + phrase_corrections = dict(base.phrase_corrections) + for phrase, correction in local.phrase_corrections: + existing = phrase_corrections.get(phrase) + if existing is not None and existing != correction: + message = f"conflicting phrase correction for {phrase!r}: {existing!r} != {correction!r}" + raise ValueError(message) + phrase_corrections[phrase] = correction return Dictionary( stems=tuple(sorted(set(base.stems) | set(local.stems))), accepted=tuple(sorted(set(base.accepted) | set(local.accepted))), corrections=tuple(sorted(corrections.items())), + phrase_corrections=tuple(sorted(phrase_corrections.items())), ignore_patterns=tuple( sorted(set(base.ignore_patterns) | set(local.ignore_patterns)) ), @@ -133,7 +187,23 @@ def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: def generate_word_mappings(dictionary: Dictionary) -> dict[str, str]: - """Expand Oxford stems and explicit words into deterministic mappings.""" + """Expand Oxford stems and explicit words into deterministic mappings. + + Parameters + ---------- + dictionary + Validated spelling policy to expand. + + Returns + ------- + dict[str, str] + Sorted accepted-word identities and spelling corrections. + + Raises + ------ + ValueError + If generated and explicit corrections conflict. + """ mappings = {word: word for word in dictionary.accepted} def add(word: str, correction: str) -> None: @@ -169,7 +239,23 @@ def _render_array(name: str, values: tuple[str, ...]) -> list[str]: def render_typos_config(dictionary: Dictionary) -> str: - """Render a deterministic, parse-checked ``typos.toml`` document.""" + """Render a deterministic, parse-checked ``typos.toml`` document. + + Parameters + ---------- + dictionary + Validated spelling policy to render. + + Returns + ------- + str + Parse-checked TOML configuration ending with a newline. + + Raises + ------ + ValueError + If generated and explicit corrections conflict. + """ lines = [ "# Generated from the shared en-GB-oxendict dictionary.", "# Regenerate with scripts/generate_typos_config.py; do not edit by hand.", @@ -193,7 +279,22 @@ def render_typos_config(dictionary: Dictionary) -> str: def write_config(path: pathlib.Path, dictionary: Dictionary) -> None: - """Atomically write validated generated configuration to *path*.""" + """Atomically write validated generated configuration to a path. + + Parameters + ---------- + path + Destination ``typos.toml`` path. + dictionary + Validated spelling policy to render and write. + + Raises + ------ + OSError + If the temporary file cannot be written or installed. + ValueError + If generated and explicit corrections conflict. + """ _atomic_write(path, render_typos_config(dictionary).encode()) @@ -219,7 +320,35 @@ def refresh_base( cache: pathlib.Path, options: RefreshOptions, ) -> RefreshResult: - """Refresh an untracked base cache when the authoritative copy is newer.""" + """Refresh an untracked base cache when the authoritative copy is newer. + + Parameters + ---------- + source + Local path or HTTPS URL for the authoritative shared dictionary. + cache + Untracked local cache destination. + options + Metadata, offline policy, and optional test transport. + + Returns + ------- + RefreshResult + Refresh status and validated cache path. + + Raises + ------ + FileNotFoundError + If offline mode has no valid cache. + NetworkUnavailableError + If the authority is unavailable and no valid stale cache exists. + InsecureSourceError + If an authority or redirect does not use HTTPS. + OSError, urllib.error.HTTPError + If persistence fails or the authority returns an HTTP error. + TypeError, ValueError + If dictionary validation fails. + """ selected_options = options if options.opener is None and urllib.request.urlopen is not _DEFAULT_URLOPEN: # Existing focused tests patch this legacy boundary; production retains diff --git a/scripts/typos_rollout_cache.py b/scripts/typos_rollout_cache.py index 9e4479e0f..e5683a2e1 100644 --- a/scripts/typos_rollout_cache.py +++ b/scripts/typos_rollout_cache.py @@ -39,7 +39,20 @@ def read(self) -> bytes: def atomic_write(path: pathlib.Path, content: bytes) -> None: - """Write content beside a path and atomically replace the destination.""" + """Write content beside a path and atomically replace the destination. + + Parameters + ---------- + path + Destination path to replace. + content + Complete bytes to install. + + Notes + ----- + The temporary file is created beside the destination, then replaced on the + same filesystem. Cleanup removes the temporary path after every outcome. + """ path.parent.mkdir(parents=True, exist_ok=True) stream = tempfile.NamedTemporaryFile( delete=False, dir=path.parent, prefix=f".{path.name}." diff --git a/scripts/typos_rollout_check.py b/scripts/typos_rollout_check.py new file mode 100644 index 000000000..278fa251e --- /dev/null +++ b/scripts/typos_rollout_check.py @@ -0,0 +1,215 @@ +#!/usr/bin/env -S uv run python +# /// script +# requires-python = ">=3.13" +# dependencies = [] +# /// +"""Enforce exact phrase corrections alongside the Typos scanner.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path +import re +import subprocess +from typing import Sequence + +import generate_typos_config as generator +import typos_rollout as rollout + + +POLICY_PATHS = frozenset({Path(".typos-oxendict-base.toml"), Path("typos.local.toml")}) + + +@dataclass(frozen=True) +class PhraseFinding: + """Describe one prohibited phrase in tracked text. + + Attributes + ---------- + path + Repository-relative path containing the phrase. + line + One-based line number of the phrase. + column + One-based column number of the phrase. + phrase + Source phrase preserving its original case. + correction + Replacement prescribed by the spelling policy. + """ + + path: Path + line: int + column: int + phrase: str + correction: str + + +def _tracked(repository: Path) -> tuple[Path, ...]: + """Return tracked paths in deterministic order. + + Parameters + ---------- + repository + Git repository whose tracked paths should be listed. + + Returns + ------- + tuple[Path, ...] + Sorted repository-relative paths. + + Raises + ------ + subprocess.CalledProcessError + If Git cannot list the repository's tracked paths. + """ + raw = subprocess.run( + ["git", "-C", str(repository), "ls-files", "-z"], + check=True, + capture_output=True, + text=True, + ).stdout + return tuple(Path(item) for item in sorted(filter(None, raw.split("\0")))) + + +def _excluded(path: Path, dictionary: rollout.Dictionary) -> bool: + """Return whether the spelling policy excludes a tracked path. + + Parameters + ---------- + path + Repository-relative path to classify. + dictionary + Merged spelling policy containing path exclusions. + + Returns + ------- + bool + ``True`` when any policy exclusion matches the path. + """ + return any( + item in path.parts or path.match(item) for item in dictionary.excluded_files + ) + + +def _masked(text: str, patterns: tuple[str, ...]) -> str: + """Replace ignored spans with position-preserving whitespace. + + Parameters + ---------- + text + UTF-8 text to scan. + patterns + Regular expressions describing ignored spans. + + Returns + ------- + str + Text with ignored characters blanked while preserving newlines. + + Raises + ------ + re.error + If an ignore pattern is not a valid regular expression. + """ + + def blank(match: re.Match[str]) -> str: + """Blank a matched span without changing its line positions.""" + return "".join("\n" if c == "\n" else " " for c in match.group()) + + for pattern in patterns: + text = re.sub(pattern, blank, text) + return text + + +def check_phrase_corrections( + repository: Path, + dictionary: rollout.Dictionary, +) -> tuple[PhraseFinding, ...]: + """Find prohibited exact phrases in tracked UTF-8 text. + + Parameters + ---------- + repository + Git repository to scan. + dictionary + Merged spelling policy containing phrase corrections, ignored spans, + and path exclusions. + + Returns + ------- + tuple[PhraseFinding, ...] + Findings in deterministic path, phrase, and source order. + + Examples + -------- + Run the checker against the current repository with an empty phrase policy: + + >>> check_phrase_corrections(Path.cwd(), rollout.Dictionary()) + () + """ + found = [] + for relative in _tracked(repository): + if relative in POLICY_PATHS or _excluded(relative, dictionary): + continue + try: + text = (repository / relative).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + masked = _masked(text, dictionary.ignore_patterns) + for phrase, correction in dictionary.phrase_corrections: + for match in re.finditer( + rf"(? int: + """Check one repository and report prohibited phrases. + + Parameters + ---------- + argv + Optional command-line arguments. Defaults to ``sys.argv`` when absent. + + Returns + ------- + int + ``0`` when no phrase is prohibited, otherwise ``2``. + + Examples + -------- + Check the current repository: + + >>> main(["--repository", "."]) # doctest: +SKIP + 0 + """ + parser = argparse.ArgumentParser() + parser.add_argument("--repository", type=Path, default=Path.cwd()) + repository = parser.parse_args(argv).repository + findings = check_phrase_corrections( + repository, + generator.dictionary_from_cache(repository), + ) + for item in findings: + print( + f"{item.path}:{item.line}:{item.column}: {item.phrase} -> {item.correction}" + ) + return 2 if findings else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/features/progress_output.feature b/tests/features/progress_output.feature index d05f2d5a9..12dfec6ca 100644 --- a/tests/features/progress_output.feature +++ b/tests/features/progress_output.feature @@ -180,7 +180,7 @@ Feature: Progress output And stdout should not contain "Task 1/2" And stderr should not contain "NINJA_STDOUT_MARKER_LINE" - Scenario: Build artifacts can be captured via stdout redirection + Scenario: Build artefacts can be captured via stdout redirection Given a minimal Netsuke workspace When netsuke is run with arguments "--progress true manifest -" Then the command should succeed diff --git a/tests/logging_stderr_tests.rs b/tests/logging_stderr_tests.rs index 0b45a45e0..16c9f7d2b 100644 --- a/tests/logging_stderr_tests.rs +++ b/tests/logging_stderr_tests.rs @@ -83,7 +83,7 @@ fn diag_json_failures_emit_single_json_document_on_stderr() -> Result<()> { } #[rstest] -fn diag_json_success_keeps_stdout_artifact_and_stderr_empty( +fn diag_json_success_keeps_stdout_artefact_and_stderr_empty( temp_with_minimal_manifest: Result, ) -> Result<()> { let temp = temp_with_minimal_manifest?; diff --git a/tests/workflow_build_and_package.rs b/tests/workflow_build_and_package.rs index 2606ffad4..4d498f82b 100644 --- a/tests/workflow_build_and_package.rs +++ b/tests/workflow_build_and_package.rs @@ -133,8 +133,8 @@ fn behavioural_build_and_package_wiring_matches_shared_actions() { "macos-package should consume staged man_path output" ); assert!( - contents.contains("${{ steps.stage_paths.outputs.artifact_dir }}"), - "workflow should use the staged artifact_dir output for uploads" + contents.contains("${{ steps.stage_paths.outputs.artefact_dir }}"), + "workflow should use the staged artefact_dir output for uploads" ); } @@ -236,13 +236,13 @@ fn goreleaser_fallback_uses_rust_target_triple_orthohelp_paths() -> Result<()> { } #[test] -fn windows_upload_includes_staged_artifact_dir() { +fn windows_upload_includes_staged_artefact_dir() { let contents = workflow_contents("build-and-package.yml") .expect("build-and-package workflow should be readable"); let step_body = workflow_step_body(&contents, "Upload Windows artefacts").join("\n"); assert!( - step_body.contains("${{ steps.stage_paths.outputs.artifact_dir }}"), + step_body.contains("${{ steps.stage_paths.outputs.artefact_dir }}"), "Windows upload should include staged sidecar artefacts" ); } diff --git a/typos.local.toml b/typos.local.toml index c0fdd7486..3f6dea221 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -12,7 +12,28 @@ accepted = [] [patterns] # The tokenizer splits the valid hyphenated prefix in `mis-grouping`. -ignore = ["mis-grouping"] +ignore = ["mis-grouping", + "(?m)^ dist/\\$\\{\\{ inputs\\['bin-name'\\] \\}\\}-\\$\\{\\{ inputs\\.version \\}\\}-\\$\\{\\{ inputs\\['artifact-suffix'\\] \\}\\}\\.pkg$", + "(?m)^ ARCHIVE_SUFFIX: \\$\\{\\{ inputs\\['artifact-suffix'\\] \\}\\}$", + "(?m)^ name: \\$\\{\\{ inputs\\['artifact-name'\\] \\}\\}$", + "(?m)^ upload-artefact: \\$\\{\\{ inputs\\['should-upload-workflow-artifacts'\\] \\}\\}$", + "(?m)^ contents\\.contains\\(\"should_upload_workflow_artifacts: \\$\\{\\{ steps\\.release_modes\\.outputs\\['should-upload-workflow-artifacts'\\] \\}\\}\"\\),$", + "(?m)^ contents\\.contains\\(\"upload-artefact: \\$\\{\\{ inputs\\['should-upload-workflow-artifacts'\\] \\}\\}\"\\),$", + "(?m)^ if: inputs\\.platform == 'linux' && inputs\\['should-upload-workflow-artifacts'\\]$", + "(?m)^ if: inputs\\.platform == 'macos' && inputs\\['should-upload-workflow-artifacts'\\]$", + "(?m)^ if: inputs\\.platform == 'windows' && inputs\\['should-upload-workflow-artifacts'\\]$", + "(?m)^ value: \\$\\{\\{ jobs\\.metadata\\.outputs\\.should_upload_workflow_artifacts \\}\\}$", + "(?m)^ artifact-name: \\$\\{\\{ needs\\.metadata\\.outputs\\.repo_name \\}\\}-windows-\\$\\{\\{ matrix\\.package_arch \\}\\}$", + "(?m)^ artifact-name:$", + "(?m)^ artifact-suffix:$", + "(?m)^ should-upload-workflow-artifacts: \\$\\{\\{ fromJSON\\(needs\\.metadata\\.outputs\\.should_upload_workflow_artifacts\\) \\}\\}$", + "(?m)^ should-upload-workflow-artifacts:$", + "(?m)^ should_upload_workflow_artifacts: \\$\\{\\{ steps\\.release_modes\\.outputs\\['should-upload-workflow-artifacts'\\] \\}\\}$", + "(?m)^ should_upload_workflow_artifacts:$", + "\\bartifact-dir\\b", + "\\bartifact-name\\b", + "\\bartifact-suffix\\b", +] [files] -exclude = [] +exclude = ["scripts/tests/test_typos_rollout_hardening.py"] diff --git a/typos.toml b/typos.toml index 409e554b6..d2cd62371 100644 --- a/typos.toml +++ b/typos.toml @@ -20,6 +20,7 @@ extend-exclude = [ "node_modules", "package-lock.json", "pnpm-lock.yaml", + "scripts/tests/test_typos_rollout_hardening.py", "target", "typos.local.toml", "typos.toml", @@ -30,7 +31,27 @@ extend-exclude = [ [default] locale = "en-gb" extend-ignore-re = [ + "(?m)^ dist/\\$\\{\\{ inputs\\['bin-name'\\] \\}\\}-\\$\\{\\{ inputs\\.version \\}\\}-\\$\\{\\{ inputs\\['artifact-suffix'\\] \\}\\}\\.pkg$", + "(?m)^ ARCHIVE_SUFFIX: \\$\\{\\{ inputs\\['artifact-suffix'\\] \\}\\}$", + "(?m)^ name: \\$\\{\\{ inputs\\['artifact-name'\\] \\}\\}$", + "(?m)^ upload-artefact: \\$\\{\\{ inputs\\['should-upload-workflow-artifacts'\\] \\}\\}$", + "(?m)^ contents\\.contains\\(\"should_upload_workflow_artifacts: \\$\\{\\{ steps\\.release_modes\\.outputs\\['should-upload-workflow-artifacts'\\] \\}\\}\"\\),$", + "(?m)^ contents\\.contains\\(\"upload-artefact: \\$\\{\\{ inputs\\['should-upload-workflow-artifacts'\\] \\}\\}\"\\),$", + "(?m)^ if: inputs\\.platform == 'linux' && inputs\\['should-upload-workflow-artifacts'\\]$", + "(?m)^ if: inputs\\.platform == 'macos' && inputs\\['should-upload-workflow-artifacts'\\]$", + "(?m)^ if: inputs\\.platform == 'windows' && inputs\\['should-upload-workflow-artifacts'\\]$", + "(?m)^ value: \\$\\{\\{ jobs\\.metadata\\.outputs\\.should_upload_workflow_artifacts \\}\\}$", + "(?m)^ artifact-name: \\$\\{\\{ needs\\.metadata\\.outputs\\.repo_name \\}\\}-windows-\\$\\{\\{ matrix\\.package_arch \\}\\}$", + "(?m)^ artifact-name:$", + "(?m)^ artifact-suffix:$", + "(?m)^ should-upload-workflow-artifacts: \\$\\{\\{ fromJSON\\(needs\\.metadata\\.outputs\\.should_upload_workflow_artifacts\\) \\}\\}$", + "(?m)^ should-upload-workflow-artifacts:$", + "(?m)^ should_upload_workflow_artifacts: \\$\\{\\{ steps\\.release_modes\\.outputs\\['should-upload-workflow-artifacts'\\] \\}\\}$", + "(?m)^ should_upload_workflow_artifacts:$", "(?s)```.*?```", + "\\bartifact-dir\\b", + "\\bartifact-name\\b", + "\\bartifact-suffix\\b", "\\brust-analyzer\\b", "`[^`\\n]+`", "mis-grouping", @@ -158,8 +179,6 @@ extend-ignore-re = [ "apologizers" = "apologizers" "apologizes" = "apologizes" "apologizing" = "apologizing" -"artifact" = "artifact" -"artifacts" = "artifacts" "atomisable" = "atomizable" "atomisably" = "atomizably" "atomisation" = "atomization" @@ -922,6 +941,7 @@ extend-ignore-re = [ "globalizers" = "globalizers" "globalizes" = "globalizes" "globalizing" = "globalizing" +"handwritten" = "handwritten" "harmonisable" = "harmonizable" "harmonisably" = "harmonizably" "harmonisation" = "harmonization" From 4fed6ba10e347ad292fa79180b54a30457e8a894 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 14 Jul 2026 02:54:32 +0200 Subject: [PATCH 4/5] Reduce spelling helper complexity Extract mapping validation, correction merging and phrase scanning into focused helpers. Preserve deterministic diagnostics and matching behaviour while satisfying the review complexity limits. Add characterization coverage for invalid policy documents, merge ordering and complete phrase-finding order. --- scripts/tests/test_typos_rollout.py | 68 +++++++++++++++-- scripts/tests/test_typos_rollout_check.py | 33 +++++++++ scripts/typos_rollout.py | 89 ++++++++++++++--------- scripts/typos_rollout_check.py | 83 ++++++++++++++------- 4 files changed, 206 insertions(+), 67 deletions(-) diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index 2556459f2..c9b019ac1 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import re import tomllib import typing as typ from pathlib import Path @@ -15,6 +16,7 @@ import types SCRIPT_DIRECTORY = Path(__file__).resolve().parents[1] +PROHIBITED_PHRASE = "hand" + "-written" def test_rollout_scripts_support_python_313() -> None: @@ -68,10 +70,12 @@ def unavailable(*_args: object, **_kwargs: object) -> None: @pytest.mark.parametrize( - "document", + ("document", "expected_error", "expected_message"), [ pytest.param( _dictionary_text().replace("schema = 1", "schema = 2"), + ValueError, + "unsupported dictionary schema 2", id="unsupported-schema", ), pytest.param( @@ -79,10 +83,14 @@ def unavailable(*_args: object, **_kwargs: object) -> None: '[oxford]\nstems = ["organ"]', 'oxford = "bad"', ), + TypeError, + "'oxford' must be a table", id="invalid-table", ), pytest.param( _dictionary_text().replace('stems = ["organ"]', "stems = [1]"), + TypeError, + "'stems' must be a list of strings", id="invalid-string-list", ), pytest.param( @@ -90,7 +98,16 @@ def unavailable(*_args: object, **_kwargs: object) -> None: "[words.corrections]", "[words.corrections]\nteh = 1", ), - id="invalid-correction", + TypeError, + "word corrections must map strings to strings", + id="invalid-word-correction", + ), + pytest.param( + _dictionary_text() + + f"\n[phrases.corrections]\n'{PROHIBITED_PHRASE}' = 1\n", + TypeError, + "phrase corrections must map strings to strings", + id="invalid-phrase-correction", ), ], ) @@ -98,13 +115,15 @@ def test_dictionary_validation_rejects_invalid_documents( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], tmp_path: Path, document: str, + expected_error: type[Exception], + expected_message: str, ) -> None: """Schema, table, string-list and correction types remain validated.""" _, rollout, _ = rollout_modules source = tmp_path / "base.toml" source.write_text(document, encoding="utf-8") - with pytest.raises((TypeError, ValueError)): + with pytest.raises(expected_error, match=rf"^{re.escape(expected_message)}$"): rollout.load_dictionary(source) @@ -113,10 +132,9 @@ def test_merge_rejects_conflicting_corrections( ) -> None: """A local overlay cannot silently weaken shared word or phrase policy.""" _, rollout, _ = rollout_modules - prohibited_phrase = "hand" + "-written" base = rollout.Dictionary( corrections=(("teh", "the"),), - phrase_corrections=((prohibited_phrase, "handwritten"),), + phrase_corrections=((PROHIBITED_PHRASE, "handwritten"),), ) merged = rollout.merge_dictionaries(base, rollout.Dictionary()) @@ -125,18 +143,52 @@ def test_merge_rejects_conflicting_corrections( "an empty local policy discarded the shared phrase correction" ) - with pytest.raises(ValueError, match="conflicting correction"): + with pytest.raises( + ValueError, + match=r"^conflicting correction for 'teh': 'the' != 'ten'$", + ): rollout.merge_dictionaries( base, rollout.Dictionary(corrections=(("teh", "ten"),)), ) - with pytest.raises(ValueError, match="conflicting phrase correction"): + with pytest.raises( + ValueError, + match=( + rf"^conflicting phrase correction for '{re.escape(PROHIBITED_PHRASE)}': " + r"'handwritten' != 'other'$" + ), + ): rollout.merge_dictionaries( base, - rollout.Dictionary(phrase_corrections=((prohibited_phrase, "other"),)), + rollout.Dictionary(phrase_corrections=((PROHIBITED_PHRASE, "other"),)), ) +def test_merge_sorts_word_and_phrase_corrections( + rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], +) -> None: + """Word and phrase policies retain deterministic key ordering.""" + _, rollout, _ = rollout_modules + base = rollout.Dictionary( + corrections=(("zeta", "last"),), + phrase_corrections=(("zeta phrase", "last phrase"),), + ) + local = rollout.Dictionary( + corrections=(("alpha", "first"),), + phrase_corrections=(("alpha phrase", "first phrase"),), + ) + + merged = rollout.merge_dictionaries(base, local) + + assert merged.corrections == (("alpha", "first"), ("zeta", "last")), ( + "word correction ordering changed" + ) + assert merged.phrase_corrections == ( + ("alpha phrase", "first phrase"), + ("zeta phrase", "last phrase"), + ), "phrase correction ordering changed" + + def test_render_and_write_are_deterministic_valid_toml( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], tmp_path: Path, diff --git a/scripts/tests/test_typos_rollout_check.py b/scripts/tests/test_typos_rollout_check.py index 22d6dd5a9..c5a7a038b 100644 --- a/scripts/tests/test_typos_rollout_check.py +++ b/scripts/tests/test_typos_rollout_check.py @@ -11,6 +11,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] PROHIBITED = "hand" + "-written" TITLE_PROHIBITED = "Hand" + "-written" +SECOND_PROHIBITED = "spell" + "-checked" @pytest.fixture(name="modules") @@ -65,6 +66,38 @@ def test_checker_boundaries_ignores_exclusions( assert actual == expected, "phrase boundaries or policy exclusions changed" +def test_checker_orders_complete_findings_by_path_phrase_and_source( + modules: tuple[types.ModuleType, types.ModuleType], + tmp_path: Path, +) -> None: + """Findings retain deterministic order, positions, case and corrections.""" + rollout, check = modules + initialize( + tmp_path, + { + "b.md": f"{SECOND_PROHIBITED} then {PROHIBITED}\n", + "a.md": (f"{PROHIBITED} then {SECOND_PROHIBITED} and {TITLE_PROHIBITED}\n"), + }, + ) + policy = rollout.Dictionary( + phrase_corrections=( + (PROHIBITED, "handwritten"), + (SECOND_PROHIBITED, "spellchecked"), + ), + ) + + actual = check.check_phrase_corrections(tmp_path, policy) + expected = ( + check.PhraseFinding(Path("a.md"), 1, 1, PROHIBITED, "handwritten"), + check.PhraseFinding(Path("a.md"), 1, 37, TITLE_PROHIBITED, "handwritten"), + check.PhraseFinding(Path("a.md"), 1, 19, SECOND_PROHIBITED, "spellchecked"), + check.PhraseFinding(Path("b.md"), 1, 20, PROHIBITED, "handwritten"), + check.PhraseFinding(Path("b.md"), 1, 1, SECOND_PROHIBITED, "spellchecked"), + ) + + assert actual == expected, "finding order or diagnostic content changed" + + def test_main_reports( modules: tuple[types.ModuleType, types.ModuleType], monkeypatch: pytest.MonkeyPatch, diff --git a/scripts/typos_rollout.py b/scripts/typos_rollout.py index 5ba533cc9..ae15dd921 100644 --- a/scripts/typos_rollout.py +++ b/scripts/typos_rollout.py @@ -76,6 +76,23 @@ def _table(document: cabc.Mapping[str, object], key: str) -> cabc.Mapping[str, o return typ.cast("cabc.Mapping[str, object]", value) +def _string_mapping( + table: cabc.Mapping[str, object], + key: str, + *, + description: str, +) -> cabc.Mapping[str, str]: + """Read and validate a string-to-string mapping from a TOML table.""" + value = _table(table, key) + if not all( + isinstance(item_key, str) and isinstance(item_value, str) + for item_key, item_value in value.items() + ): + message = f"{description} must map strings to strings" + raise TypeError(message) + return typ.cast("cabc.Mapping[str, str]", value) + + def _dictionary_from_text(text: str) -> Dictionary: """Parse and validate shared dictionary text.""" document = tomllib.loads(text) @@ -88,21 +105,16 @@ def _dictionary_from_text(text: str) -> Dictionary: phrases = _table(document, "phrases") patterns = _table(document, "patterns") files = _table(document, "files") - corrections_table = _table(words, "corrections") - if not all( - isinstance(key, str) and isinstance(value, str) - for key, value in corrections_table.items() - ): - message = "word corrections must map strings to strings" - raise TypeError(message) - corrections = typ.cast("cabc.Mapping[str, str]", corrections_table) - phrase_table = _table(phrases, "corrections") - if not all( - isinstance(k, str) and isinstance(v, str) for k, v in phrase_table.items() - ): - message = "phrase corrections must map strings to strings" - raise TypeError(message) - phrase_corrections = typ.cast("cabc.Mapping[str, str]", phrase_table) + corrections = _string_mapping( + words, + "corrections", + description="word corrections", + ) + phrase_corrections = _string_mapping( + phrases, + "corrections", + description="phrase corrections", + ) return Dictionary( stems=_string_list(oxford, "stems"), accepted=_string_list(words, "accepted"), @@ -136,6 +148,25 @@ def load_dictionary(path: pathlib.Path) -> Dictionary: return _dictionary_from_text(path.read_text(encoding="utf-8")) +def _merge_correction_items( + base: tuple[tuple[str, str], ...], + local: tuple[tuple[str, str], ...], + *, + label: str, +) -> tuple[tuple[str, str], ...]: + """Merge correction items while rejecting conflicting replacements.""" + merged = dict(base) + for source, correction in local: + existing = merged.get(source) + if existing is not None and existing != correction: + message = ( + f"conflicting {label} for {source!r}: {existing!r} != {correction!r}" + ) + raise ValueError(message) + merged[source] = correction + return tuple(sorted(merged.items())) + + def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: """Merge a shared dictionary with a non-conflicting local overlay. @@ -156,27 +187,19 @@ def merge_dictionaries(base: Dictionary, local: Dictionary) -> Dictionary: ValueError If the local policy conflicts with a shared correction. """ - corrections = dict(base.corrections) - for word, correction in local.corrections: - existing = corrections.get(word) - if existing is not None and existing != correction: - message = ( - f"conflicting correction for {word!r}: {existing!r} != {correction!r}" - ) - raise ValueError(message) - corrections[word] = correction - phrase_corrections = dict(base.phrase_corrections) - for phrase, correction in local.phrase_corrections: - existing = phrase_corrections.get(phrase) - if existing is not None and existing != correction: - message = f"conflicting phrase correction for {phrase!r}: {existing!r} != {correction!r}" - raise ValueError(message) - phrase_corrections[phrase] = correction return Dictionary( stems=tuple(sorted(set(base.stems) | set(local.stems))), accepted=tuple(sorted(set(base.accepted) | set(local.accepted))), - corrections=tuple(sorted(corrections.items())), - phrase_corrections=tuple(sorted(phrase_corrections.items())), + corrections=_merge_correction_items( + base.corrections, + local.corrections, + label="correction", + ), + phrase_corrections=_merge_correction_items( + base.phrase_corrections, + local.phrase_corrections, + label="phrase correction", + ), ignore_patterns=tuple( sorted(set(base.ignore_patterns) | set(local.ignore_patterns)) ), diff --git a/scripts/typos_rollout_check.py b/scripts/typos_rollout_check.py index 278fa251e..4cc2531e1 100644 --- a/scripts/typos_rollout_check.py +++ b/scripts/typos_rollout_check.py @@ -123,6 +123,58 @@ def blank(match: re.Match[str]) -> str: return text +def _phrase_findings( + path: Path, + text: str, + masked: str, + policy: tuple[str, str], +) -> tuple[PhraseFinding, ...]: + """Find one prohibited phrase in position-preserving masked text.""" + phrase, correction = policy + found = [] + for match in re.finditer( + rf"(? tuple[PhraseFinding, ...]: + """Find all prohibited phrases in one eligible tracked UTF-8 file.""" + if relative in POLICY_PATHS or _excluded(relative, dictionary): + return () + try: + text = (repository / relative).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return () + masked = _masked(text, dictionary.ignore_patterns) + return tuple( + finding + for policy in dictionary.phrase_corrections + for finding in _phrase_findings( + relative, + text, + masked, + policy, + ) + ) + + def check_phrase_corrections( repository: Path, dictionary: rollout.Dictionary, @@ -149,32 +201,11 @@ def check_phrase_corrections( >>> check_phrase_corrections(Path.cwd(), rollout.Dictionary()) () """ - found = [] - for relative in _tracked(repository): - if relative in POLICY_PATHS or _excluded(relative, dictionary): - continue - try: - text = (repository / relative).read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): - continue - masked = _masked(text, dictionary.ignore_patterns) - for phrase, correction in dictionary.phrase_corrections: - for match in re.finditer( - rf"(? int: From 74cb02fb843d2e2720d398a0aa504c5dda8bcf36 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 14 Jul 2026 03:00:40 +0200 Subject: [PATCH 5/5] Reduce spelling validation test arguments Bundle the expected exception class and diagnostic into one parametrized value so each named validation case remains exact while meeting the review argument limit. --- scripts/tests/test_typos_rollout.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/scripts/tests/test_typos_rollout.py b/scripts/tests/test_typos_rollout.py index c9b019ac1..b1429e8aa 100644 --- a/scripts/tests/test_typos_rollout.py +++ b/scripts/tests/test_typos_rollout.py @@ -70,12 +70,11 @@ def unavailable(*_args: object, **_kwargs: object) -> None: @pytest.mark.parametrize( - ("document", "expected_error", "expected_message"), + ("document", "expected"), [ pytest.param( _dictionary_text().replace("schema = 1", "schema = 2"), - ValueError, - "unsupported dictionary schema 2", + (ValueError, "unsupported dictionary schema 2"), id="unsupported-schema", ), pytest.param( @@ -83,14 +82,12 @@ def unavailable(*_args: object, **_kwargs: object) -> None: '[oxford]\nstems = ["organ"]', 'oxford = "bad"', ), - TypeError, - "'oxford' must be a table", + (TypeError, "'oxford' must be a table"), id="invalid-table", ), pytest.param( _dictionary_text().replace('stems = ["organ"]', "stems = [1]"), - TypeError, - "'stems' must be a list of strings", + (TypeError, "'stems' must be a list of strings"), id="invalid-string-list", ), pytest.param( @@ -98,15 +95,13 @@ def unavailable(*_args: object, **_kwargs: object) -> None: "[words.corrections]", "[words.corrections]\nteh = 1", ), - TypeError, - "word corrections must map strings to strings", + (TypeError, "word corrections must map strings to strings"), id="invalid-word-correction", ), pytest.param( _dictionary_text() + f"\n[phrases.corrections]\n'{PROHIBITED_PHRASE}' = 1\n", - TypeError, - "phrase corrections must map strings to strings", + (TypeError, "phrase corrections must map strings to strings"), id="invalid-phrase-correction", ), ], @@ -115,11 +110,11 @@ def test_dictionary_validation_rejects_invalid_documents( rollout_modules: tuple[types.ModuleType, types.ModuleType, types.ModuleType], tmp_path: Path, document: str, - expected_error: type[Exception], - expected_message: str, + expected: tuple[type[Exception], str], ) -> None: """Schema, table, string-list and correction types remain validated.""" _, rollout, _ = rollout_modules + expected_error, expected_message = expected source = tmp_path / "base.toml" source.write_text(document, encoding="utf-8")