From 0d6fad2883ca708048667f2c613ac07489685544 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:47:58 -0700 Subject: [PATCH 1/3] Enforce the house style in CI Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +++- CONTRIBUTING.md | 8 ++++ RELEASING.md | 5 +++ tools/check_style.py | 91 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tools/check_style.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 170cec5..d59dd05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,8 @@ # # tests: the 0.1 acceptance criteria, executable. # layering: emet_sdk imports nothing internal; emet_hal and emet_engine -# import emet_sdk only. +# import emet_sdk only. The same job runs the release +# invariants and the house-style check. # # The layering check exists because the closed-engine plan used to enforce that # boundary structurally, and an open monorepo does not. See DISTRIBUTION.md §2. @@ -46,6 +47,11 @@ jobs: - name: Check release invariants run: python tools/release_check.py . + # House style, mechanised because 0.3 removed 178 em-dashes by hand + # after they had passed review one at a time. See CONTRIBUTING.md. + - name: Check house style + run: python tools/check_style.py . + dco: name: dco sign-off runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 29a3239..22aca43 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,11 +185,19 @@ Before opening a PR, run what CI runs: ```sh python tools/check_layering.py . python tools/release_check.py . +python tools/check_style.py . cd emet-sdk && python -m pytest -q cd ../emet-hal && python -m pytest -q cd ../emet-engine && python -m pytest -q ``` +## House style + +Plain declarative sentences, in code comments and docs alike. No em-dashes: +a comma, a colon, or two sentences. None of the six words that read as a +press release; `tools/check_style.py` lists them, enforces both rules, and +runs in CI, so a stray dash fails the build rather than a review. + CI also builds the three wheels and installs them outside the source tree, so a packaging mistake that an editable install hides still fails the pipeline. The exact steps are in `.github/workflows/ci.yml`. diff --git a/RELEASING.md b/RELEASING.md index 152fa58..0d86702 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,8 +11,13 @@ Run the mechanical half first. It is fast and it fails loudly: ```sh python tools/check_layering.py . python tools/release_check.py . +python tools/check_style.py . ``` +The tag itself is made by `tools/tag_release.py`, which runs those three +again and refuses a dirty tree, a branch other than master, a version the +packages do not declare, an unsigned key, or a tag that already exists. + Everything below is what a script cannot check. --- diff --git a/tools/check_style.py b/tools/check_style.py new file mode 100644 index 0000000..18f9ddd --- /dev/null +++ b/tools/check_style.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Enforce the house style that a review keeps missing. + + em-dashes none, anywhere in the tree: a comma, a colon, or two + sentences instead + banned words delve, leverage, robust, seamless, comprehensive, + underscore, in prose + +**Why this exists.** 0.3 removed 178 em-dashes by hand from code, comments, +schemas, examples and docs after they had crept in one at a time over three +releases. Each one had passed review, because a reviewer reading for meaning +does not see punctuation. A script does. The banned words are the ones that +read as press release rather than as a person; CONTRIBUTING.md has the rule. + +The check is deliberately blunt. There is no allowlist and no way to mark a +line as exempt, because every exemption is the first of many. If a legitimate +case appears, change the rule here, in one place, with a reason. + +Dependency-free and short enough to read, like `check_layering.py`. + +Usage: python tools/check_style.py [repo_root] +Exit: 0 clean, 1 problems found. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +EM_DASH = "—" + +#: Prose that sounds like marketing. Word-bounded, case-insensitive. +BANNED = ("delve", "leverage", "robust", "seamless", "comprehensive", "underscore") + +#: What counts as text worth checking. +TEXT_SUFFIXES = {".py", ".md", ".txt", ".toml", ".yaml", ".yml", ".json", ".cfg", ".ini", ".sh", ".ps1"} + +#: Never descend into these. +SKIP_DIRS = {".git", ".venv", "__pycache__", "dist", "build", ".pytest_cache", "node_modules"} + +#: Third-party text the project did not write and should not rewrite, plus +#: this file, which has to name what it bans. +SKIP_FILES = {"CODE_OF_CONDUCT.md", "LICENSE", "check_style.py"} + +BANNED_RE = re.compile(r"\b(" + "|".join(BANNED) + r")\b", re.IGNORECASE) + + +def text_files(root: Path): + for path in sorted(root.rglob("*")): + if any(part in SKIP_DIRS or part.endswith(".egg-info") for part in path.parts): + continue + if not path.is_file() or path.name in SKIP_FILES: + continue + if path.suffix in TEXT_SUFFIXES or path.name in ("NOTICE",): + yield path + + +def check(root: Path) -> list[str]: + problems: list[str] = [] + for path in text_files(root): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + rel = path.relative_to(root).as_posix() + for n, line in enumerate(lines, 1): + if EM_DASH in line: + problems.append(f"{rel}:{n}: em-dash. Use a comma, a colon, or two sentences.") + for m in BANNED_RE.finditer(line): + problems.append(f"{rel}:{n}: {m.group(0)!r}. Say the plain thing.") + return problems + + +def main(argv: list[str]) -> int: + root = Path(argv[1] if len(argv) > 1 else ".").resolve() + if not (root / "emet-sdk").exists(): + print(f"style-check: {root} does not look like the Emet repository", file=sys.stderr) + return 1 + problems = check(root) + if not problems: + print("style-check: ok") + return 0 + for p in problems: + print(f" x {p}") + print(f"\n{len(problems)} problem(s).") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) From 727dcbd33e809605c47b5ee682a3affd3e5e2313 Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:47:59 -0700 Subject: [PATCH 2/3] Check the HAL README against the entry points Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- tools/release_check.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tools/release_check.py b/tools/release_check.py index 0a99bff..3801472 100644 --- a/tools/release_check.py +++ b/tools/release_check.py @@ -13,6 +13,7 @@ versions all three packages agree plugins every discovery group has a shipped implementation docs nothing advertises a version the code no longer is + hal readme every shipped entry point is named in emet-hal/README.md markers no TODO or FIXME left in shipped source **Why this exists.** 0.3's scope was "audio in/out, wake word, VAD, @@ -133,6 +134,28 @@ def check_groups(root: Path) -> None: notes.append(f"{total} entry points across {len(provided)} groups") +def check_hal_readme(root: Path) -> None: + """Every entry point emet-hal registers is named in its README. + + 0.3 shipped seven new plugins and the README's "what ships" table kept + listing the four from 0.2, through several rounds of "what is left". + A newcomer reads that table before anything else. + """ + pyproject = root / "emet-hal" / "pyproject.toml" + readme = root / "emet-hal" / "README.md" + if not pyproject.exists() or not readme.exists(): + return + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + text = readme.read_text(encoding="utf-8") + for group, entries in (data.get("project", {}).get("entry-points") or {}).items(): + for name in entries: + if f"`{name}`" not in text: + problem( + f"emet-hal/README.md does not name the {group} entry point `{name}`. " + f"The 'what ships' table is the first thing a contributor reads." + ) + + def check_docs(root: Path, version: str | None) -> None: """Nothing should advertise a version the code no longer is.""" if not version: @@ -178,6 +201,7 @@ def main(argv: list[str]) -> int: check_single_declaration(root) check_groups(root) check_docs(root, version) + check_hal_readme(root) check_markers(root) for note in notes: From e58a46ba5287ac479da8ef0fd33524ccb776599b Mon Sep 17 00:00:00 2001 From: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:47:59 -0700 Subject: [PATCH 3/3] Make release tags by script Signed-off-by: Alexander Wang <87671725+alexander-wang03@users.noreply.github.com> --- tools/tag_release.py | 147 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tools/tag_release.py diff --git a/tools/tag_release.py b/tools/tag_release.py new file mode 100644 index 0000000..5e5afe6 --- /dev/null +++ b/tools/tag_release.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Make the release tag the way the project makes release tags. + +Signed, annotated, from the release-notes file, on a clean master that +matches origin, at the version the three packages declare. Every check here +is a mistake that has been made or nearly made: + + unsigned tag v0.3 first went up with `git tag -a`, no `-s`, and showed + Unverified on GitHub + wrong branch a tag on a feature branch points at a commit master + never had + dirty tree a tag with uncommitted work nearby is a tag of something + nobody can rebuild + behind origin a tag on a local master that has not pulled the squash + commit points at the wrong parent + version drift the tag says 0.3, a pyproject still says 0.2 + +Nothing here bypasses the person: it runs `git tag` and, only with `--push`, +`git push`. It never commits and never touches the index. + +Usage: + python tools/tag_release.py 0.3 --notes ../release-notes/v0.3.txt + python tools/tag_release.py 0.3 --notes ../release-notes/v0.3.txt --push + python tools/tag_release.py 0.3 --notes ../release-notes/v0.3.txt --dry-run +Exit: 0 tagged (or dry run printed), 1 a check failed. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tomllib +from pathlib import Path + +PACKAGES = ("emet-sdk", "emet-hal", "emet-engine") + + +def git(root: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=root, check=True, capture_output=True, text=True, encoding="utf-8" + ).stdout.strip() + + +def fail(msg: str) -> int: + print(f"tag-release: {msg}", file=sys.stderr) + return 1 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="tag_release.py", description=__doc__.split("\n\n")[0]) + parser.add_argument("version", help="the release, as 0.N or 0.N.M") + parser.add_argument("--notes", required=True, help="release-notes file; becomes the tag message") + parser.add_argument("--push", action="store_true", help="push the tag to origin after making it") + parser.add_argument("--dry-run", action="store_true", help="run the checks and print the commands only") + parser.add_argument("--root", default=".", help="repository root (default: .)") + args = parser.parse_args(argv[1:]) + + root = Path(args.root).resolve() + if not (root / "emet-sdk").exists(): + return fail(f"{root} does not look like the Emet repository") + + series = args.version + full = series if series.count(".") == 2 else f"{series}.0" + tag = f"v{series}" + notes = Path(args.notes).resolve() + + # The notes file, before anything else: it is the tag message. + if not notes.exists(): + return fail(f"notes file not found: {notes}") + first = notes.read_text(encoding="utf-8").splitlines()[0] if notes.stat().st_size else "" + if not first.startswith(f"{series}:"): + return fail(f"first line of {notes.name} is {first!r}; expected it to start with {series + ':'!r}") + + # The three packages agree with the tag. + for pkg in PACKAGES: + data = tomllib.loads((root / pkg / "pyproject.toml").read_text(encoding="utf-8")) + declared = data["project"]["version"] + if declared != full: + return fail(f"{pkg}/pyproject.toml declares {declared}, the tag says {full}") + + # Master, clean, and level with origin. + branch = git(root, "rev-parse", "--abbrev-ref", "HEAD") + if branch != "master": + return fail(f"on branch {branch!r}; release tags are made on master") + if git(root, "status", "--porcelain"): + return fail("the working tree is not clean; commit or stash first") + git(root, "fetch", "--quiet", "origin", "master", "--tags") + if git(root, "rev-parse", "HEAD") != git(root, "rev-parse", "origin/master"): + return fail("local master and origin/master differ; pull (or push) first") + + # Not twice. + if git(root, "tag", "--list", tag): + return fail(f"{tag} already exists locally. To replace it: git tag -d {tag}, then run again") + if git(root, "ls-remote", "--tags", "origin", tag): + return fail(f"{tag} already exists on origin. Replacing a pushed tag is a deliberate act; see RELEASE_PROCESS.md") + + # Signing is configured, so `-s` will succeed rather than prompt. + try: + key = git(root, "config", "--get", "user.signingkey") + except subprocess.CalledProcessError: + key = "" + if not key: + return fail("git has no user.signingkey; the tag would be unsigned. Set gpg.format and user.signingkey first") + + # The mechanical release checks, because a tag is a promise they held. + py = sys.executable + for script in ("tools/check_layering.py", "tools/release_check.py", "tools/check_style.py"): + result = subprocess.run([py, str(root / script), str(root)], cwd=root, capture_output=True, text=True) + if result.returncode != 0: + print(result.stdout, end="") + return fail(f"{script} failed; fix that before tagging") + + commands = [ + ["git", "tag", "-s", tag, "-F", str(notes)], + ["git", "tag", "-v", tag], + ] + if args.push: + commands.append(["git", "push", "origin", tag]) + + print(f"tag-release: {tag} at {git(root, 'rev-parse', '--short', 'HEAD')} ({first})") + for cmd in commands: + shown = " ".join(cmd) + if args.dry_run: + print(f" would run: {shown}") + continue + print(f" {shown}") + result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, encoding="utf-8") + if result.stdout.strip(): + print(" " + result.stdout.strip().replace("\n", "\n ")) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + return fail(f"{shown} failed") + if result.stderr.strip() and cmd[1] == "tag" and "-v" in cmd: + print(" " + result.stderr.strip().replace("\n", "\n ")) + + if not args.push and not args.dry_run: + print(f"tag-release: made and verified. Push it with: git push origin {tag}") + print( + f"tag-release: then the GitHub release: gh release create {tag} --verify-tag " + f"--prerelease --title \"{first}\" --notes-file \"{notes}\"" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))