diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 62610ca..ea72925 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -66,10 +66,10 @@ jobs: - name: Check repo gates step run: python3 scripts/repo_gate.py - # The charset and duplicate-word rules are clean tree-wide, so they gate. + # The charset, duplicate-word and spelling rules are clean tree-wide, so they gate. # Every other prose rule reports in the step below without gating. - name: Check prose step - run: python3 scripts/prose_lint.py . --check charset --check dupword + run: python3 scripts/prose_lint.py . --check charset --check dupword --check spelling # Warn-only, and visible rather than absent: an unrun check is one nobody acts on. # The backlog is corrected as each file is next edited, never swept. diff --git a/host-setup/agent-safety/gh-write-guard.py b/host-setup/agent-safety/gh-write-guard.py index abd115a..47bc3bc 100644 --- a/host-setup/agent-safety/gh-write-guard.py +++ b/host-setup/agent-safety/gh-write-guard.py @@ -57,7 +57,7 @@ # --- Bypass-of-branch-rule detectors (Rule 4) -------------------------------------------------------- # A git operation is denied when it would only succeed by bypassing an active branch rule - the harm is # that the maintainer's admin identity CAN bypass, so a plain-looking push silently lands on a protected -# branch. The judgement is made against the branch's *live* rules (self-configuring: a code-style develop +# branch. The judgment is made against the branch's *live* rules (self-configuring: a code-style develop # carries `pull_request` and is denied, a config-style develop does not and is allowed), except for the # explicit-bypass flags below, which are the bypass by definition and need no query. # diff --git a/reports/vscode-server-dotnetcore/audit.md b/reports/vscode-server-dotnetcore/audit.md index e0008dd..1d1f66b 100644 --- a/reports/vscode-server-dotnetcore/audit.md +++ b/reports/vscode-server-dotnetcore/audit.md @@ -85,7 +85,7 @@ None. No applicable check fails both letter and intent. 9. **Dispatch guard skips instead of failing fast.** `publish-release.yml:33` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)** 10. **Docker-only release attaches repo files without the `expect_release_assets` mechanism.** The bespoke `build-release-task.yml:117-128` lists `LICENSE`/`README.md` directly with `fail_on_unmatched_files: true` rather than the template's `expect_release_assets: false` + `release-asset-*` pattern download. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome-NonRoot, which reached tag-only by omitting `fail_on_unmatched_files`). 11. **README reshaped from the canonical structure.** `## License` is the first section (`README.md:6`); no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues`; headings are flat `##` siblings; all links inline, not reference-style. Content complete; shape non-canonical - more minimal than the other docker repos. -12. **`HISTORY.md` overstates the publish model.** `HISTORY.md:8` says the weekly run "publishes both `main` ... and `develop`", but the schedule is `main`-only (`publish-release.yml:12-16`) and `develop` publishes only via manual dispatch. Minor doc/behaviour mismatch. +12. **`HISTORY.md` overstates the publish model.** `HISTORY.md:8` says the weekly run "publishes both `main` ... and `develop`", but the schedule is `main`-only (`publish-release.yml:12-16`) and `develop` publishes only via manual dispatch. Minor doc/behavior mismatch. 13. **`develop` diverged from `main`** (ahead 1, behind 19). See Develop Drift. ## Proposed Registry / Spec Updates diff --git a/scripts/README.md b/scripts/README.md index 25be707..b4ab767 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,7 +18,7 @@ uvx coverage@latest run --source=. -m unittest discover -s scripts && uvx covera ## `prose_lint.py` -Enforces the [`GOVERNANCE.md`][governance] "Documentation Style Conventions" rules that no linter checks: non-ASCII judged against the charset rule's three tiers, a semicolon in prose, a spaced hyphen joining or interrupting a sentence, a duplicated consecutive word, and the shape of a comment's prose. +Enforces the [`GOVERNANCE.md`][governance] "Documentation Style Conventions" rules that no linter checks: non-ASCII judged against the charset rule's three tiers, a semicolon in prose, a spaced hyphen joining or interrupting a sentence, a duplicated consecutive word, a British spelling, and the shape of a comment's prose. The tiers decide by context rather than by a flat ban. Tier 1 carries no meaning its ASCII form loses and always flags. Tier 2 is an operator, kept next to a figure or another operator and replaced between words, so a threshold table reads as the range it is. Tier 3 is a unit or scientific symbol whose ASCII form would be a lie and never flags. Developer-typed characters such as emoji are preserved regardless of tier, and an un-tiered one is still reported as `charset-unknown` until it is classified. @@ -30,7 +30,11 @@ Run it scoped to changed lines, matching the standing rule that existing prose i python3 scripts/prose_lint.py . --diff origin/develop ``` -Whole-tree (`python3 scripts/prose_lint.py .`) reports the legacy backlog as well, which is informational rather than a gate. `charset` and `dupword` are clean tree-wide, so CI gates those two and reports the rest warn-only. +Whole-tree (`python3 scripts/prose_lint.py .`) reports the legacy backlog as well, which is informational rather than a gate. `charset`, `dupword` and `spelling` are clean tree-wide, so CI gates those three and reports the rest warn-only. + +The `spelling` rule covers the US English convention where cspell does not reach. That gate reads README and HISTORY only, deliberately, because gating every markdown file would mean endlessly padding `cspell.json` with technical terms, so a British spelling anywhere else in the tree had nothing checking it. The banned words are generated from stems rather than listed one by one, since an inflected spelling is as wrong as its base and a hand-listed family drifts as soon as one form is added without the others. Two words are deliberately absent: `analyses` is the US plural of `analysis` as much as it is a British verb form, and `cancelled` is a GitHub Actions job status rather than prose. + +**Outside markdown the rule reads the comments, not the source lines**, reusing the extraction the `comment-wrap` rule already does. An identifier, a string literal, or a lookup table is code, and judging it as prose would make this script report its own table of banned words. **Scope** is every text file git tracks, binaries skipped by a NUL-byte check, with no extension allowlist: an allowlist covers what its author thought of and silently stops covering whatever is added next, which is the same reason the line-endings rule already requires `git ls-files` over a raw `find`. `--list-files` prints the discovered set for auditing. diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 13adbd2..a4602fe 100644 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -11,6 +11,7 @@ comment-case A comment sentence starts with a capital, not a lowercase word. dupword No duplicated consecutive word. sentence-split A sentence must not wrap across lines (one sentence per line). + spelling No British spelling, the repo-wide convention being US English. Exit 1 if any violation is found. Read-only, never edits. """ @@ -29,8 +30,10 @@ 'comment-case': 'a comment sentence opening in lowercase', 'dupword': 'a duplicated consecutive word', 'sentence-split': 'a sentence wrapping across lines', + 'spelling': 'a British spelling where the repo convention is US English', } -DEFAULT_RULES = frozenset({'charset', 'charset-unknown', 'semicolon', 'dash', 'dupword'}) +DEFAULT_RULES = frozenset({'charset', 'charset-unknown', 'semicolon', 'dash', 'dupword', + 'spelling'}) # Produced rather than authored trees, consulted only on the no-git fallback path. # Where git can answer, its own ignore rules are the better answer. @@ -183,6 +186,67 @@ def discover(paths: list[str], excludes: tuple[str, ...] = ()) -> list[Path]: DUPWORD = re.compile(r'(? dict[str, str]: + """Every banned spelling mapped to the US form that replaces it.""" + words = dict(BRITISH_ODD) + for stem in ISE_STEMS: + words.update({stem + gb: stem + us for gb, us in ISE_ENDINGS}) + for stem in OUR_STEMS: + words.update({f'{stem}our{end}': f'{stem}or{end}' for end in OUR_ENDINGS}) + for stem in RE_STEMS: + words.update({stem + gb: stem + us for gb, us in RE_ENDINGS}) + return words + + +BRITISH = british_spellings() +# Longest alternative first, so an inflection is read whole rather than as its base word. +BRITISH_RE = re.compile(r'\b(?:' + '|'.join(sorted(BRITISH, key=len, reverse=True)) + r')\b', + re.IGNORECASE) + + +def us_form(found: str) -> str: + """The US spelling for a match, carrying the case the source wrote it in.""" + us = BRITISH[found.lower()] + if found.isupper(): + return us.upper() + return us.capitalize() if found[0].isupper() else us + # Comment syntax per language, since the rule governs every comment the fleet's types carry. # A `doc` marker opens a documentation comment, which CODESTYLE governs and may run to paragraphs. # `raw` names the quotes whose strings embed the delimiter by doubling it. @@ -218,7 +282,8 @@ class Syntax(TypedDict): # A YAML block scalar is the multi-line form. # A quote delimits a scalar only at the start of a value, so a plain scalar's apostrophe is text. # Such a quote must also not carry, since one `don't` would blank the rest of the file. -YAML: Syntax = {**HASH, 'raw': "'", 'quote_after': ':-,[{', 'escape_in': '"', +# The dash leads `quote_after` so the set does not read as a character range. +YAML: Syntax = {**HASH, 'raw': "'", 'quote_after': '-:,[{', 'escape_in': '"', 'carry': frozenset({'block'})} # A TOML literal string is raw the same way, while its basic string keeps the backslash escape. TOML: Syntax = {**HASH, 'raw': "'", 'escape_in': '"'} @@ -707,6 +772,12 @@ def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]: lines = raw.split('\n') if {'comment-wrap', 'comment-case'} & rules: out.extend(f for f in comment_wrap_findings(path, raw, lines) if f[1] in rules) + # Outside markdown the prose lives in the comments, and the rule judges prose rather than code. + # Reading the source line itself would flag an identifier, or in this file the rule's own table. + comments: dict[int, list[str]] = {} + if 'spelling' in rules and path.suffix != '.md': + for ln, text, _ in extracted_comments(path, lines): + comments.setdefault(ln, []).append(text) in_fence = False prev_txt = '' prev_no = 0 @@ -749,6 +820,13 @@ def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]: continue out.append((i, 'dupword', f"duplicated word '{m.group(1)}'")) + if 'spelling' in rules: + texts = [prose] if path.suffix == '.md' else comments.get(i, []) + for m in BRITISH_RE.finditer(strip_inline_code(' '.join(texts))): + found = m.group(0) + out.append((i, 'spelling', + f"British spelling '{found}' -> '{us_form(found)}'")) + if 'sentence-split' in rules and path.suffix == '.md': stripped = txt.strip() is_prose = (stripped and not stripped.startswith(('|', '>', '#')) diff --git a/scripts/test_prose_lint.py b/scripts/test_prose_lint.py index d9ee500..ec1e66b 100644 --- a/scripts/test_prose_lint.py +++ b/scripts/test_prose_lint.py @@ -943,6 +943,74 @@ def test_the_rule_is_markdown_only(self) -> None: {'sentence-split'}, name='bait.py')) +class TestSpelling(BaitCase): + """US English, read from markdown prose and from the comments of every other syntax. + + The rule runs on whatever file it is handed, README and HISTORY included. It complements the + cspell gate rather than dividing the tree with it: cspell reads those two files and this reads + the prose in all of them, so what it adds is coverage of everywhere cspell was never pointed. + """ + + def messages(self, text: str, name: str = 'bait.md') -> list[str]: + path = self.tmp / name + path.write_text(text, encoding='utf-8') + return [msg for _, _, msg in prose_lint.check_file(path, {'spelling'})] + + def test_every_banned_spelling_is_caught_and_its_us_form_is_not(self) -> None: + """The live table drives the case, so a word added to it arrives already proven.""" + for british, us in prose_lint.BRITISH.items(): + with self.subTest(word=british): + self.assertEqual(['spelling'], self.kinds(f'One {british} here.\n', {'spelling'})) + self.assertEqual([], self.kinds(f'One {us} here.\n', {'spelling'})) + + def test_the_finding_names_the_replacement(self) -> None: + word = 'behavi' + 'our' + self.assertEqual([f"British spelling '{word}' -> 'behavior'"], + self.messages(f'The {word} of it.\n')) + + def test_a_match_keeps_the_case_the_source_wrote(self) -> None: + """A capitalized or shouted word gets a replacement it can be swapped in for.""" + word = 'colo' + 'ur' + for written, offered in ((word.capitalize(), 'Color'), (word.upper(), 'COLOR')): + with self.subTest(written=written): + self.assertEqual([f"British spelling '{written}' -> '{offered}'"], + self.messages(f'{written} of the box.\n')) + + def test_a_word_that_is_also_correct_us_english_is_not_banned(self) -> None: + """`analyses` is the plural of `analysis`, and `cancelled` is an Actions job status.""" + for word in ('analyses', 'cancelled', 'analysis', 'advertise', 'surprise', 'exercise'): + with self.subTest(word=word): + self.assertEqual([], self.kinds(f'One {word} here.\n', {'spelling'})) + + def test_a_banned_spelling_inside_a_word_is_not_a_match(self) -> None: + """The pattern is word-anchored, so a longer word that contains one is left alone.""" + for word in ('parameter', 'collaborate', 'metering'): + with self.subTest(word=word): + self.assertEqual([], self.kinds(f'One {word} here.\n', {'spelling'})) + + def test_code_is_not_prose_outside_markdown(self) -> None: + """A source file is read through its comments, so an identifier or a table is not bait. + + This is what keeps prose_lint.py from reporting its own lookup table. + """ + word = 'organis' + 'ation' + self.assertEqual([], self.kinds(f"x = '{word}'\n", {'spelling'}, name='bait.py')) + self.assertEqual(['spelling'], + self.kinds(f'# The {word} of it.\n', {'spelling'}, name='bait.py')) + + def test_inline_code_is_not_prose(self) -> None: + """A backticked word is a quoted token, the same exemption every other prose rule takes.""" + word = 'licen' + 'ce' + self.assertEqual([], self.kinds(f'The `{word}` field.\n', {'spelling'})) + self.assertEqual([], self.kinds(f'# The `{word}` field.\n', {'spelling'}, name='bait.py')) + + def test_the_repo_is_clean_of_british_spellings(self) -> None: + """The rule gates in CI, so the tree it gates has to pass it today and not eventually.""" + found = [f'{prose_lint.rel(p)}:{ln}' for p in prose_lint.discover(['.']) + for ln, kind, _ in prose_lint.check_file(p, {'spelling'}) if kind == 'spelling'] + self.assertEqual([], found) + + class TestSyntaxDispatch(unittest.TestCase): def test_an_extensionless_file_is_read_as_hash_commented(self) -> None: """A shebang script or a config with no suffix is far more often `#` than nothing.""" diff --git a/spec/validate.py b/spec/validate.py index cccaa55..2afa797 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -332,7 +332,7 @@ def check_selector(where, applies_to): div = load("spec/divergences.json") repo_names = {r.get("name") for r in repos["repos"] if isinstance(r, dict)} manifest_paths = {i.get("path") for i in baseline if isinstance(i, dict)} - # A verbatim section is an addressable unit too, labelled "path > section" (matches fidelity_honesty's + # A verbatim section is an addressable unit too, labeled "path > section" (matches fidelity_honesty's # SECTION_SEP), so a section-scoped divergence can carry its own disposition. Only well-formed section # entries produce a label - a malformed one is already reported by the files.json checks above. for i in baseline: