Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion host-setup/agent-safety/gh-write-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
2 changes: 1 addition & 1 deletion reports/vscode-server-dotnetcore/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
82 changes: 80 additions & 2 deletions scripts/prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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.
Expand Down Expand Up @@ -183,6 +186,67 @@ def discover(paths: list[str], excludes: tuple[str, ...] = ()) -> list[Path]:
DUPWORD = re.compile(r'(?<![\w/-])(\w+)\s+\1\b', re.IGNORECASE)
SENT_END = re.compile(r'[.!?:]["\')\]]?\s*$')

# US English is a repo-wide rule, and the cspell gate reads README and HISTORY only.
# A British spelling anywhere else in the tree therefore reaches main unchallenged.
# Each family generates its own inflections, since an inflected spelling is as wrong as its base.
# A hand-listed family drifts the moment one form is added without the others.
# The cross product also generates forms no stem takes, which simply never match.
# `analyses` is omitted, being the US plural of `analysis` as much as a British verb form.
# `cancelled` is omitted, being the GitHub Actions job status rather than prose.
ISE_STEMS = ('author', 'custom', 'initial', 'maxim', 'minim', 'normal', 'optim', 'organ',
'priorit', 'recogn', 'serial', 'special', 'standard', 'summar', 'synchron',
'util', 'visual')
ISE_ENDINGS = (('ise', 'ize'), ('ised', 'ized'), ('ises', 'izes'), ('ising', 'izing'),
('isation', 'ization'), ('isations', 'izations'))
OUR_STEMS = ('behavi', 'col', 'fav', 'flav', 'hon', 'lab', 'neighb')
OUR_ENDINGS = ('', 's', 'ed', 'ing', 'al', 'ally', 'ful', 'ite', 'ites')
RE_STEMS = ('cent', 'fib', 'lit', 'met', 'theat')
RE_ENDINGS = (('re', 'er'), ('res', 'ers'), ('red', 'ered'))
# The spellings that follow no family, each with the US form that replaces it.
BRITISH_ODD = {
'analyse': 'analyze', 'analysed': 'analyzed', 'analysing': 'analyzing',
'artefact': 'artifact', 'artefacts': 'artifacts',
'catalogue': 'catalog', 'catalogues': 'catalogs', 'catalogued': 'cataloged',
'defence': 'defense', 'defences': 'defenses',
'fulfil': 'fulfill', 'fulfils': 'fulfills', 'fulfilment': 'fulfillment',
'judgement': 'judgment', 'judgements': 'judgments',
'labelled': 'labeled', 'labelling': 'labeling',
'licence': 'license', 'licences': 'licenses',
'modelled': 'modeled', 'modelling': 'modeling',
'offence': 'offense', 'offences': 'offenses',
'practise': 'practice', 'practised': 'practiced', 'practising': 'practicing',
'programme': 'program', 'programmes': 'programs',
'signalled': 'signaled', 'signalling': 'signaling',
'travelled': 'traveled', 'travelling': 'traveling',
'whilst': 'while',
}


def british_spellings() -> 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.
Expand Down Expand Up @@ -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': '"'}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(('|', '>', '#'))
Expand Down
68 changes: 68 additions & 0 deletions scripts/test_prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion spec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading