From aa85f74faa975c28b9f3f330dcff337dec9d733c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 25 Oct 2026 10:51:01 -0700 Subject: [PATCH 1/3] Check the Prose Gate for Vocabulary Instead of Counting Its Lines (#3474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the line ceiling on `.github/actions/prose-gate/prose_lint.py` with a check aimed at what the ceiling stood for, per the maintainer's decision on [#3441](https://github.com/ptr727/ProjectTemplate/issues/3441). The ceiling's own docstring said vocabulary and exemptions live in `prose-gate.json`, and a line count could not tell that defect from parser logic, so each lexer fix moved the ceiling or compacted unrelated code to fit. `TestVocabularyLivesInTheData` in `tests/test_prose_lint.py` now fails where the script holds either of these: - A string equal to a banned spelling, as `prose_lint.british_spellings` generates them, or to an entry of an exemption list. Stem and ending fragments and the US forms are not entries, since they are ordinary words a lexer may spell. Neither are `service_accounts`, names like `root` an identifier can use. - A collection literal of more than five words, counted through nested literals and reported once at the outermost. A word is letters alone, with inner spaces, hyphens, and apostrophes allowed, so flags, paths, and environment variable names are not words. `GIT_LOCATION` therefore settles without an exemption by name. A mapping's key counts only beside a value holding words, so `RULE_TABLE`'s dispatch keys do not. Each shape has a case on a constructed script, and the real script holds none. ## Verification - Full unit suite, `python -m unittest discover -s tests` with `tests/requirements.txt`: 2947 tests, OK, 7 skipped. - ruff format and check, mypy: clean. `prose_lint.py --diff` against the merge base, with and without `--check sentence-length`: clean. - On a copy of the real script with `"judgement"`, a six-word nested tuple, and a `hub_hosted` path appended, the check reports all three. Renaming `GIT_LOCATION` reports nothing, since its entries are not words. - Three local adversarial passes, at the frontier tier. The first found stem fragments and US forms read as entries, nested literals uncounted, flags counted as words, and an overclaiming docstring, each fixed. The second found two cases that proved nothing, both fixed. The third found nothing. ## Declined local findings - **Path and directive lists are not counted toward the cap** (introduced). Paths and directive names are also the script's own logic, its argv and the file names it reads, so counting them false-fires the way counting flags did. An exact copy of an existing entry is still caught by equality. - **Counting stops at a `frozenset(...)` call** (introduced). Counting through calls false-fires on `RULE_TABLE`, the gate's own dispatch idiom, whose hyphenated word keys sit over `frozenset` groups. ## Pre-existing findings, not filed - `.github/actions/prose-gate/prose_lint.py` `TERMINATOR`: an abbreviation list (`e.g`, `i.e`, `vs`, `etc`) sits inside a regex, a shape no literal check reads (pre-existing). - A word list written as one string split at runtime, `"a b c".split()`, reads as one constant and is not counted (pre-existing, no instance in the script). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- tests/test_prose_lint.py | 138 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 6 deletions(-) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 16b4881de..e67b5796c 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -10,6 +10,7 @@ from __future__ import annotations +import ast import contextlib import functools import io @@ -27,7 +28,7 @@ import unittest from collections.abc import Iterator from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar from unittest import mock from host_capability import bash_or_skip @@ -7487,17 +7488,142 @@ def test_the_gate_reads_the_resolved_base(self) -> None: ) +VOCABULARY_CAP = 5 +COLLECTIONS = (ast.Set, ast.List, ast.Tuple, ast.Dict) + + +def is_word(text: str) -> bool: + """Whether `text` is letters alone, so flags, paths, and environment variable names are not.""" + return re.fullmatch(r"[A-Za-z]+(?:[ '-][A-Za-z]+)*", text) is not None + + +def words_in(node: ast.expr | None) -> int: + """How many words a literal holds, nested literals included and a mapping's keys only beside one.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return int(is_word(node.value)) + if isinstance(node, (ast.Set, ast.List, ast.Tuple)): + return sum(words_in(element) for element in node.elts) + if isinstance(node, ast.Dict): + held = [words_in(value) for value in node.values] + return sum(n + words_in(key) for key, n in zip(node.keys, held) if n) + return 0 + + +def data_entries(data: dict[str, dict[str, Any]]) -> set[str]: + """The banned spellings and the exemption lists' entries, which the script may not spell itself. + + The stems and endings are fragments and the US forms are ordinary words, so neither is an + entry. Neither are service accounts, since they are names like `root` an identifier can use. + """ + entries = set(prose_lint.british_spellings(data["spelling"])) + for name, values in data["exemptions"].items(): + if name != "service_accounts": + entries.update(values) + return entries + + +def vocabulary_in(source: str, data: dict[str, dict[str, Any]]) -> list[str]: + """Where `source` holds spellings or exemptions that belong in `data`, the gate's data file. + + A literal of more than `VOCABULARY_CAP` words reads as a vocabulary rather than as logic. It is + reported once, at the outermost literal holding it. + """ + entries = data_entries(data) + nested: set[int] = set() + found = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Constant) and node.value in entries: + found.append(f"{node.lineno}: {node.value!r} is a data file entry") + if not isinstance(node, COLLECTIONS): + continue + children = node.elts if not isinstance(node, ast.Dict) else [*node.keys, *node.values] + nested.update(id(child) for child in children if isinstance(child, COLLECTIONS)) + words = words_in(node) + if id(node) not in nested and words > VOCABULARY_CAP: + found.append(f"{node.lineno}: a literal of {words} words") + return found + + +class TestVocabularyLivesInTheData(unittest.TestCase): + """Vocabulary and exemptions live in the data file, so the script holding them is a defect.""" + + DATA: ClassVar[dict[str, dict[str, Any]]] = { + "spelling": { + "odd": {"whilst": "while"}, + "ise_stems": ["real"], + "ise_endings": [["ise", "ize"]], + "our_stems": [], + "our_endings": [], + "re_stems": [], + "re_endings": [], + }, + "exemptions": { + "dupword_allow": ["had had"], + "hub_hosted": ["OPERATIONS.md"], + "service_accounts": ["root"], + }, + } + + def test_the_gate_holds_none(self) -> None: + data = json.loads( + (PROSE_LINT_SCRIPT.parent / "prose-gate.json").read_text(encoding="utf-8") + ) + self.assertEqual([], vocabulary_in(PROSE_LINT_SCRIPT.read_text(encoding="utf-8"), data)) + + def test_a_word_list_past_the_cap_is_vocabulary(self) -> None: + words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot"] + found = vocabulary_in(f"WORDS = frozenset({set(words)!r})\n", self.DATA) + self.assertEqual(["1: a literal of 6 words"], found) + self.assertEqual([], vocabulary_in(f"WORDS = {words[:VOCABULARY_CAP]!r}\n", self.DATA)) + + def test_a_word_mapping_counts_both_sides(self) -> None: + found = vocabulary_in('WORDS = {"aa": "bb", "cc": "dd", "ee": "ff"}\n', self.DATA) + self.assertEqual(["1: a literal of 6 words"], found) + + def test_nested_word_lists_count_once_at_the_outermost(self) -> None: + for source, words in ( + ('PAIRS = (("grey", "gray"), ("kerb", "curb"), ("tyre", "tire"))\n', 6), + ('GROUPS = {"a": ["w", "x", "y"], "b": ["z", "v", "u"]}\n', 8), + ('INNER = [["a", "b", "c", "d", "e", "f"]]\n', 6), + ): + with self.subTest(source=source): + found = vocabulary_in(source, self.DATA) + self.assertEqual([f"1: a literal of {words} words"], found) + + def test_a_dispatch_table_and_punctuation_are_logic(self) -> None: + rules = ("charset", "dash", "dupword", "semicolon", "spelling", "sentence-length") + names = ", ".join(f'"{rule}": (len, frozenset({{"prose", "comment"}}))' for rule in rules) + source = f"TABLE = {{{names}}}\nMARKS = ('.', '!', '?', ':', ';', ',', '|')\n" + self.assertEqual([], vocabulary_in(source, self.DATA)) + + def test_flags_and_environment_names_are_not_words(self) -> None: + source = ( + 'ARGS = ("--unified=0", "--no-color", "--no-ext-diff", "-z", "--name-only", "--cached")\n' + 'GIT = frozenset({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", ' + '"GIT_NAMESPACE", "GIT_COMMON_DIR"})\n' + ) + self.assertEqual([], vocabulary_in(source, self.DATA)) + + def test_a_data_file_entry_copied_into_the_script(self) -> None: + for entry in ("whilst", "realise", "had had", "OPERATIONS.md"): + with self.subTest(entry=entry): + found = vocabulary_in(f"if word == {entry!r}:\n pass\n", self.DATA) + self.assertEqual([f"1: {entry!r} is a data file entry"], found) + + def test_fragments_us_forms_and_field_names_are_not_entries(self) -> None: + for literal in ("while", "real", "ise", "root", "odd", "ise_stems"): + with self.subTest(literal=literal): + self.assertEqual( + [], vocabulary_in(f"if word == {literal!r}:\n pass\n", self.DATA) + ) + + class TestHarness(unittest.TestCase): def test_this_module_collects_a_plausible_number_of_cases(self) -> None: """A module whose cases fail to load still reports OK, which is a pass proving nothing.""" loaded = unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__]) self.assertGreaterEqual(loaded.countTestCases(), 100) - def test_the_gate_stays_under_its_size_ceiling(self) -> None: - """Vocabulary and exemptions live in the data file, so the script holding them is a defect.""" - lines = PROSE_LINT_SCRIPT.read_text(encoding="utf-8").count("\n") - self.assertLessEqual(lines, 1862) - if __name__ == "__main__": unittest.main(verbosity=2) From 86f66b45d82cc6f16dd10354efe7074a30ddfb0a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 25 Oct 2026 11:10:03 -0700 Subject: [PATCH 2/3] Report a Word Tuple Keying a Mapping in the Vocabulary Check (#3477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Answers the finding promotion [#3476](https://github.com/ptr727/ProjectTemplate/pull/3476) raised on the vocabulary check [#3474](https://github.com/ptr727/ProjectTemplate/pull/3474) added to `tests/test_prose_lint.py`. - **The `is_word` docstring** said "letters alone", while its pattern joins letter runs with single spaces, hyphens, or apostrophes. It now says that. It also names which paths and environment variable names are not words, those with a separator or an underscore, since an all-letter name such as `HOME` is one. The test covering them is renamed to match. - **A word tuple keying a mapping beside a value holding no words was never reported** (class sweep, found by the local pass). The mapping does not count such a key, yet marked it nested, so the key literal was skipped as well. Only a key the mapping counts is nested now, and the docstring says a literal reports at the outermost literal that counts it. One case proves each half: `KEYED` reports at the tuple, and `COUNTED` reports once, at the mapping. ## Verification - `tests/test_prose_lint.py`: 629 tests, OK. ruff format and check, mypy: clean. `prose_lint.py --diff` against the merge base, with and without `--check sentence-length`: clean. - Three local adversarial passes at the frontier tier. The first found the remaining environment-name overclaim and the skipped key, the second two unproven halves and two wording gaps, each fixed. The third found nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- tests/test_prose_lint.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index e67b5796c..8b4f8986e 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -7493,7 +7493,11 @@ def test_the_gate_reads_the_resolved_base(self) -> None: def is_word(text: str) -> bool: - """Whether `text` is letters alone, so flags, paths, and environment variable names are not.""" + """Whether `text` is letter runs joined by single spaces, hyphens, or apostrophes. + + So "had had" and "sentence-length" are words, while flags, paths with a separator, and + environment variable names with an underscore are not. + """ return re.fullmatch(r"[A-Za-z]+(?:[ '-][A-Za-z]+)*", text) is not None @@ -7526,7 +7530,7 @@ def vocabulary_in(source: str, data: dict[str, dict[str, Any]]) -> list[str]: """Where `source` holds spellings or exemptions that belong in `data`, the gate's data file. A literal of more than `VOCABULARY_CAP` words reads as a vocabulary rather than as logic. It is - reported once, at the outermost literal holding it. + reported once, at the outermost literal that counts it. """ entries = data_entries(data) nested: set[int] = set() @@ -7536,7 +7540,11 @@ def vocabulary_in(source: str, data: dict[str, dict[str, Any]]) -> list[str]: found.append(f"{node.lineno}: {node.value!r} is a data file entry") if not isinstance(node, COLLECTIONS): continue - children = node.elts if not isinstance(node, ast.Dict) else [*node.keys, *node.values] + if isinstance(node, ast.Dict): + counted = [key for key, value in zip(node.keys, node.values) if words_in(value)] + children: list[ast.expr | None] = [*counted, *node.values] + else: + children = list(node.elts) nested.update(id(child) for child in children if isinstance(child, COLLECTIONS)) words = words_in(node) if id(node) not in nested and words > VOCABULARY_CAP: @@ -7585,6 +7593,8 @@ def test_nested_word_lists_count_once_at_the_outermost(self) -> None: ('PAIRS = (("grey", "gray"), ("kerb", "curb"), ("tyre", "tire"))\n', 6), ('GROUPS = {"a": ["w", "x", "y"], "b": ["z", "v", "u"]}\n', 8), ('INNER = [["a", "b", "c", "d", "e", "f"]]\n', 6), + ('KEYED = {("aa", "bb", "cc", "dd", "ee", "ff"): len}\n', 6), + ('COUNTED = {("aa", "bb", "cc", "dd", "ee", "ff"): ["x"]}\n', 7), ): with self.subTest(source=source): found = vocabulary_in(source, self.DATA) @@ -7596,7 +7606,7 @@ def test_a_dispatch_table_and_punctuation_are_logic(self) -> None: source = f"TABLE = {{{names}}}\nMARKS = ('.', '!', '?', ':', ';', ',', '|')\n" self.assertEqual([], vocabulary_in(source, self.DATA)) - def test_flags_and_environment_names_are_not_words(self) -> None: + def test_flags_and_environment_names_with_an_underscore_are_not_words(self) -> None: source = ( 'ARGS = ("--unified=0", "--no-color", "--no-ext-diff", "-z", "--name-only", "--cached")\n' 'GIT = frozenset({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", ' From 18ee2fcd070ce7dace74d74c592b649348740cfa Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 25 Oct 2026 11:34:16 -0700 Subject: [PATCH 3/3] Prove Paths Are Not Words in the Vocabulary Check (#3478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Answers the second-round finding promotion [#3476](https://github.com/ptr727/ProjectTemplate/pull/3476) raised on the vocabulary check: the `is_word` docstring says a path with a separator is not a word, and no case held a path. The case covering flags and underscored names, renamed `test_flags_paths_and_underscored_names_are_not_words`, now carries six slash paths and six backslash paths in a tuple apiece, so a pattern accepting either separator pushes its tuple past the five-word cap. ## Verification - `tests/test_prose_lint.py`: 629 tests, OK. ruff and mypy clean. `prose_lint.py --diff` against the merge base, with and without `--check sentence-length`: clean. - With `/` replaced by a space in the case's source, it reports `PATHS` at 6 words, and with `\` replaced, `WINDOWS` at 6 words. - Two local adversarial passes at the frontier tier. The first found a single backslash path could not cross the cap by itself, fixed with its own tuple. The second found nothing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- tests/test_prose_lint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 8b4f8986e..64a6285b6 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -7606,9 +7606,11 @@ def test_a_dispatch_table_and_punctuation_are_logic(self) -> None: source = f"TABLE = {{{names}}}\nMARKS = ('.', '!', '?', ':', ';', ',', '|')\n" self.assertEqual([], vocabulary_in(source, self.DATA)) - def test_flags_and_environment_names_with_an_underscore_are_not_words(self) -> None: + def test_flags_paths_and_underscored_names_are_not_words(self) -> None: source = ( 'ARGS = ("--unified=0", "--no-color", "--no-ext-diff", "-z", "--name-only", "--cached")\n' + 'PATHS = ("docs/notes", "src/app", "repo-config/labels", "a/b", "x/y", "lib/core")\n' + 'WINDOWS = ("docs\\\\notes", "src\\\\app", "a\\\\b", "x\\\\y", "lib\\\\core", "c\\\\d")\n' 'GIT = frozenset({"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX", ' '"GIT_NAMESPACE", "GIT_COMMON_DIR"})\n' )