diff --git a/.github/scripts/check_site_voice.py b/.github/scripts/check_site_voice.py new file mode 100644 index 00000000..fde82e1c --- /dev/null +++ b/.github/scripts/check_site_voice.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Issue #338 - site/VOICE.md's punctuation rule, finally enforced. + +`site/VOICE.md` has said this since 2026-07-02: + + Em-dashes are not used as sentence separators in site prose. Restructure + with a period, a comma, a colon, or parentheses instead. + +Nothing enforced it. `_sloplib.in_antislop_doc_scope` governed repo-root docs +and `docs/**` and had never covered `site/`, so the rule was prose asking +politely - and 16 of the 36 authored pages violated it, for 24 days, while +reviewers cited it at contributors. + +A rule with no gate is worse than no rule. It is not merely unenforced: people +learn that the style docs are advisory, which is the same failure mode as a +teardown report that over-counts its failures. So this is the gate. + +SCOPE IS AUTHORED PROSE, and the exclusions are load-bearing rather than +convenient: + + * `site/src/content/docs/reference/**` - 91 of the 128 pages there are + generated on every build. Flagging them would report findings nobody wrote + and nobody can fix in place. + * `site/src/content/docs/changelog.md` - a verbatim pass-through of the repo + CHANGELOG, which is payload prose under a different register. + * `site/src/curated/**` - mirrors of `plugins/ca` bodies. Framework prose, + already excluded via `plugins/`; mirroring it into the site must not smuggle + it back into scope. + +The file set comes from `git ls-files`, so a generated page cannot drift into +scope by being written into a tracked directory: if it is not committed, it is +not audited. + +KNOWN GAP, stated rather than discovered later. The detector is line-based: it +requires word characters on both sides of the dash ON THE SAME LINE. A separator +dash that lands at a line-wrap boundary - + ...it holds in one of three states — + listed below. +- is therefore invisible to it, and three such dashes were found by hand in +`codearbiter-directory.md` while fixing the flagged ones. They were fixed too, +but the gate did not catch them and would not catch a new one. Closing that +needs paragraph-level analysis rather than a per-line scan, which is a larger +change than this gate; it is filed separately rather than implied to be handled. + +Usage: + python .github/scripts/check_site_voice.py # audit, exit 1 on findings + python .github/scripts/check_site_voice.py --list # paths only, exit 0 +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "plugins" / "ca" / "hooks")) + +from _sloplib import find_prose_separator_dashes, in_antislop_doc_scope # noqa: E402 + +SITE_PROSE = "site/src/content/docs" + + +def audited_paths() -> list[str]: + """Every git-TRACKED page under the authored site-prose root that the shared + scope predicate governs. One source of truth for scope: this script does not + re-implement the exclusions, it asks `in_antislop_doc_scope`.""" + tracked = subprocess.run( + ["git", "ls-files", SITE_PROSE], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", check=True, + ).stdout.split() + return sorted(p for p in tracked if in_antislop_doc_scope(p)) + + +def audit() -> dict[str, list[dict]]: + out: dict[str, list[dict]] = {} + for rel in audited_paths(): + text = (REPO / rel).read_text(encoding="utf-8", errors="replace") + hits = find_prose_separator_dashes(text) + if hits: + out[rel] = hits + return out + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--list", action="store_true", help="print the audited paths and exit 0") + arguments = parser.parse_args(argv) + + paths = audited_paths() + if arguments.list: + for rel in paths: + print(rel) + return 0 + + if not paths: + print("::error::no authored site prose found to audit - the scope predicate has drifted", + file=sys.stderr) + return 1 + + findings = audit() + total = sum(len(v) for v in findings.values()) + if not findings: + print(f"site voice: {len(paths)} authored page(s) clean of prose separator dashes") + return 0 + + print(f"::error::site/VOICE.md forbids the em-dash as a sentence separator in site prose; " + f"{total} line(s) across {len(findings)} file(s) still use one. Restructure with a " + f"period, a comma, a colon, or parentheses.", file=sys.stderr) + for rel, hits in findings.items(): + for hit in hits: + print(f" {rel}:{hit['line']}: {hit['context'][:160]}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c6b9bad1..a0dc70a3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,6 +23,11 @@ on: - "site/**" - "plugins/ca/**" # the reference is generated from the plugin, so rebuild when it changes - ".github/workflows/docs.yml" # this workflow gates the site graph; it must see its own edits + # Issue #338: the site-voice gate and the detector it calls. Without these + # a PR that only loosened the rule would skip the job that enforces it - + # the same defect class the impact filter already guards for ci.yml. + - ".github/scripts/check_site_voice.py" + - "core/pysrc/_sloplib.py" pull_request: branches: [main] paths: @@ -74,6 +79,23 @@ jobs: run: npm audit --omit=dev --audit-level=high - name: Typecheck run: npm run typecheck + - name: Enforce site/VOICE.md punctuation (issue #338) + # site/VOICE.md has banned the em-dash as a sentence separator in site + # prose since 2026-07-02, and NOTHING enforced it: _sloplib's scope + # covered repo-root docs and docs/**, never site/. 16 of the 36 authored + # pages violated the rule for 24 days while reviewers cited it at + # contributors. A rule with no gate is worse than no rule - people learn + # that the style docs are advisory. + # + # `deploy` needs this job, so a violation blocks the publish. Scope is + # AUTHORED prose only, drawn from `git ls-files` via the shared + # in_antislop_doc_scope: generated reference pages, the changelog + # pass-through, and the curated plugin mirrors are all excluded, because + # a finding nobody wrote and nobody can fix in place is noise. + # + # Runs from the repo root, not site/, since it audits tracked paths. + working-directory: . + run: python .github/scripts/check_site_voice.py - name: Test run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 66848130..464714bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,25 @@ predate the plugin rewrite and are grouped by date. ### Fixed +- site/VOICE.md's punctuation rule is finally enforced. It has banned the + em-dash as a sentence separator in site prose since 2026-07-02, and nothing + checked it: the anti-slop detector's scope covered repo-root docs and + `docs/**` and had never included `site/`. Sixteen of the thirty-six authored + pages violated the rule for twenty-four days while reviewers cited it at + contributors. A rule with no gate is worse than no rule, because people learn + that the style docs are advisory. 118 offending lines across 15 pages are + restructured with a period, a comma, a colon, or parentheses, and + `check_site_voice.py` now blocks the site publish on a new one (issue #338). +- The detector no longer flags a definition-list dash (`- **term** - meaning`). + The rule is about *sentence* separators, and VOICE.md's own Terminology + anchors are written in that exact form, so enforcing it as written would have + made the gate contradict the guide. A real separator later on the same line is + still caught: only the definition dash is dropped, never the term before it. + The first cut of that exemption blanked the whole lead-in and silently + suppressed a genuine finding whenever inline code followed it, which an + adversarial reader caught before it shipped. +- The detector's scope predicate recognises `.mdx` as prose. It keyed on `.md` + alone, so two authored site pages were invisible to a rule about writing. - The shipped `farm.js` bundle is now executed by the test suite, not merely regenerated and byte-compared. `includes/farm.md` tells operators to run `node /tools/farm.js`, but the integration launcher ran `farm.ts` diff --git a/core/pysrc/_sloplib.py b/core/pysrc/_sloplib.py index 36c42377..413fe6e3 100644 --- a/core/pysrc/_sloplib.py +++ b/core/pysrc/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/package.json b/package.json index d97ca794..ab4e7a49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.25", + "version": "0.1.26", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/plugins/ca-codex/hooks/_sloplib.py b/plugins/ca-codex/hooks/_sloplib.py index 36c42377..413fe6e3 100644 --- a/plugins/ca-codex/hooks/_sloplib.py +++ b/plugins/ca-codex/hooks/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md index 9837f4ab..20c2ab9c 100644 --- a/plugins/ca-pi/CHANGELOG.md +++ b/plugins/ca-pi/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to `ca-pi` are documented in this file. +## [0.1.26] - 2026-07-25 + +### Fixed + +- The anti-slop dash detector no longer flags a definition-list dash + (`- **term** - meaning`). The rule it enforces is about SENTENCE separators, + and the style guides are themselves written in that form, so the detector was + contradicting the guides it exists to serve. A real separator later on the + same line is still reported: only the definition dash is dropped, never the + term before it (issue #338). + +### Changed + +- The detector's document scope now covers authored site prose, and recognises + `.mdx` as prose rather than keying on `.md` alone. + ## [0.1.25] - 2026-07-25 ### Fixed diff --git a/plugins/ca-pi/hooks/_sloplib.py b/plugins/ca-pi/hooks/_sloplib.py index 36c42377..413fe6e3 100644 --- a/plugins/ca-pi/hooks/_sloplib.py +++ b/plugins/ca-pi/hooks/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json index 568fbc83..2559fe37 100644 --- a/plugins/ca-pi/package.json +++ b/plugins/ca-pi/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.25", + "version": "0.1.26", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/plugins/ca/hooks/_sloplib.py b/plugins/ca/hooks/_sloplib.py index 36c42377..413fe6e3 100644 --- a/plugins/ca/hooks/_sloplib.py +++ b/plugins/ca/hooks/_sloplib.py @@ -34,10 +34,28 @@ # A letter or digit (Unicode), used to confirm a dash actually joins two text # spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell). _WORD_RE = re.compile(r"[^\W_]", re.UNICODE) +# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence +# separator, and the rule this detector enforces says "sentence separators" - +# site/VOICE.md's own Terminology anchors section is written in exactly this +# form, so flagging it would make the gate contradict the style guide it exists +# to enforce (#338). Anchored to the start of the line and to a bolded lead-in, +# so an ordinary sentence that happens to contain bold text is untouched. +# +# The TERM is captured and kept; only the dash is dropped. Blanking the whole +# lead-in was the first cut and it was a false-negative generator: on +# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls. +# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks, +# and the SECOND dash - a real separator - was left with no word character on +# its left and escaped. That loosened the shared detector for docs/** and +# repo-root too, not only for site prose. +_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]") def _prose_only(line): """Drop the spans §3.A exempts so only candidate prose remains.""" + # Only the definition DASH is dropped; the term is kept (group 1), so a + # later separator on the same line still has its left-hand context. + line = _DEFINITION_RE.sub(r"\1 ", line) line = _INLINE_CODE_RE.sub(" ", line) line = _URL_RE.sub(" ", line) line = _RANGE_RE.sub(" ", line) @@ -78,19 +96,44 @@ def find_prose_separator_dashes(text): return findings +# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence +# separators since 2026-07-02, and nothing enforced it: this predicate covered +# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 +# of its own 36 authored files, is worse than no rule - reviewers cite it and it +# is wrong. +# +# Scope is AUTHORED prose only. Everything under content/docs/reference/ is +# generated on every build (91 of the 128 files there), changelog.md is a +# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors +# plugins/ca bodies - which are framework prose under a different register, and +# already excluded via plugins/. Flagging any of those would report a finding +# nobody wrote and nobody can fix in place. +_SITE_PROSE_ROOT = "site/src/content/docs/" +_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md") + + def in_antislop_doc_scope(rel_path): """True for user-facing Markdown the anti-slop bundle governs: repo-root - community docs and docs/**. Excludes codeArbiter's own framework bodies - (everything under plugins/) and machine-managed .codearbiter/ state.""" + community docs, docs/**, and AUTHORED site prose under + site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies + (everything under plugins/), machine-managed .codearbiter/ state, and every + generated site page.""" if not rel_path: return False p = rel_path.replace("\\", "/") if p.startswith("./"): p = p[2:] - if not p.lower().endswith(".md"): + # `.mdx` counts: the rule is about prose, not about a file extension, and + # two authored site pages are .mdx. + if not p.lower().endswith((".md", ".mdx")): return False if p.startswith("plugins/") or p.startswith(".codearbiter/"): return False + if p.startswith(_SITE_PROSE_ROOT): + rest = p[len(_SITE_PROSE_ROOT):] + return not rest.startswith(_SITE_PROSE_EXCLUDED) + if p.startswith("site/"): + return False if p.startswith("docs/"): return True return "/" not in p diff --git a/plugins/ca/hooks/tests/test_sloplib.py b/plugins/ca/hooks/tests/test_sloplib.py index 590595a6..841a4444 100644 --- a/plugins/ca/hooks/tests/test_sloplib.py +++ b/plugins/ca/hooks/tests/test_sloplib.py @@ -119,5 +119,108 @@ def test_empty_or_falsy_path_out_of_scope(self): self.assertFalse(S.in_antislop_doc_scope(None)) + + +class TestSitePoseScope(unittest.TestCase): + """#338 - site/VOICE.md has banned em-dashes as sentence separators since + 2026-07-02, and nothing enforced it: `in_antislop_doc_scope` covered + repo-root docs and docs/**, never site/. A rule with no gate, violated in 16 + of its own 36 authored files, is worse than no rule - reviewers cite it and + it is wrong. + + The scope is AUTHORED site prose only. Everything generated is excluded, by + path, because the generator would otherwise be flagged for output nobody + writes by hand and nobody can fix in place.""" + + def test_authored_site_docs_in_scope(self): + for p in ( + "site/src/content/docs/overview.md", + "site/src/content/docs/guides/uninstalling.md", + "site/src/content/docs/concepts/hardening-history.md", + "site/src/content/docs/getting-started/install.md", + "site/src/content/docs/feature-forge/index.md", + ): + self.assertTrue(S.in_antislop_doc_scope(p), p) + + def test_generated_reference_pages_out_of_scope(self): + # 91 of the 128 files under content/docs are generated into reference/ + # on every build. They are not authored, not committed, and not fixable + # in place. + for p in ( + "site/src/content/docs/reference/commands.md", + "site/src/content/docs/reference/agents/backend-author.md", + ): + self.assertFalse(S.in_antislop_doc_scope(p), p) + + def test_generated_changelog_page_out_of_scope(self): + # Verbatim pass-through of the repo CHANGELOG, which is payload prose + # under a different voice. + self.assertFalse(S.in_antislop_doc_scope("site/src/content/docs/changelog.md")) + + def test_curated_plugin_mirrors_out_of_scope(self): + # site/src/curated/** mirrors plugins/ca bodies. Those are framework + # prose governed by the plugin's own register, already excluded via + # plugins/ - mirroring them into site must not smuggle them back in. + for p in ( + "site/src/curated/agents/backend-author.md", + "site/src/curated/commands/fix.md", + ): + self.assertFalse(S.in_antislop_doc_scope(p), p) + + def test_site_source_that_is_not_docs_prose_out_of_scope(self): + for p in ("site/README.md", "site/VOICE.md", "site/src/components/Thing.md"): + self.assertFalse(S.in_antislop_doc_scope(p), p) + + def test_mdx_authored_pages_are_in_scope_too(self): + # Two authored pages are .mdx. The predicate keyed on ".md" only, so + # they were invisible to a rule that is about prose, not file format. + self.assertTrue(S.in_antislop_doc_scope("site/src/content/docs/index.mdx")) + + + + +class TestDefinitionListDashExempt(unittest.TestCase): + """#338 - the rule is about SENTENCE separators. A definition-list dash is + structural, and site/VOICE.md's own Terminology anchors are written in that + exact form, so flagging it would make the gate contradict the guide.""" + + def test_definition_list_dash_is_not_a_finding(self): + for line in ( + "- **gate** — a phase exit condition (STOP/BLOCK).", + "* **lane** — a sanctioned path through the system.", + "**skill** — an orchestrator routine with phases.", + ): + self.assertEqual(S.find_prose_separator_dashes(line), [], line) + + def test_a_real_separator_after_a_definition_is_still_caught(self): + # Only the definition DASH is dropped, never the term before it. A + # second dash on the same line is still a separator and must not ride in + # behind the exemption. + line = "- **gate** — a phase exit condition — and it blocks the call." + self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + + def test_definition_exemption_survives_inline_code_after_it(self): + # The regression this exemption first shipped with, and the reason the + # test above was not enough: dropping the whole `- **term**` lead-in left + # the NEXT dash with no word character on its left once + # _INLINE_CODE_RE blanked the backticks, so a real separator vanished. + # Verbatim from site/src/content/docs/getting-started/compatibility.md, + # which the detector scored 1 before the exemption and 0 after. + line = "- **The gate-enforcement hooks** — `pre-bash.py`, `pre-write.py` — make **zero** network calls." + self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + + def test_definition_term_is_preserved_for_downstream_checks(self): + # Stated as a property, not an implementation detail: whatever the + # exemption does, the term's own words must survive into the analysed + # text, or any later dash on the line loses its left-hand context. + self.assertIn("gate", S._prose_only("- **gate** — a phase exit condition")) + + def test_bold_mid_sentence_is_not_treated_as_a_definition(self): + # The exemption is anchored to the start of the line, so an ordinary + # sentence containing bold text keeps its finding. + line = "The **commit gate** runs nine checks — every one must be green." + self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + + if __name__ == "__main__": unittest.main() diff --git a/site/src/content/docs/codearbiter-directory.md b/site/src/content/docs/codearbiter-directory.md index b07c4cf8..979a25e9 100644 --- a/site/src/content/docs/codearbiter-directory.md +++ b/site/src/content/docs/codearbiter-directory.md @@ -4,8 +4,8 @@ description: "Every file and directory under .codearbiter/, what writes it, what --- `.codearbiter/` is codeArbiter's project-state store: a root-level directory, outside `.claude/`, -so it survives even if the plugin itself is uninstalled. It holds the project's own record — its -spec, task board, decisions, and audit trail — separate from the plugin code that reads and +so it survives even if the plugin itself is uninstalled. It holds the project's own record (its +spec, task board, decisions, and audit trail), separate from the plugin code that reads and writes it. `/ca:init` scaffolds it; `/ca:create-context` (existing codebase) or `/ca:decompose` (greenfield) populates it. Its presence and content is the plugin's actual contract with your repo: if you want to know what codeArbiter knows about your project, or what it will do next, @@ -22,23 +22,23 @@ those are noted below. | Path | Written by | Read by | Editable by hand? | |---|---|---|---| -| `CONTEXT.md` | `/ca:init`, `/ca:decompose`, `/ca:create-context` | every enforcement hook (`arbiter_active()`), the orchestrator | Yes, but the frontmatter is guarded — see below | -| `open-tasks.md` | `/ca:task` only | `/ca:status`, the statusline, `SessionStart` | No — guarded to `/ca:task` | +| `CONTEXT.md` | `/ca:init`, `/ca:decompose`, `/ca:create-context` | every enforcement hook (`arbiter_active()`), the orchestrator | Yes, but the frontmatter is guarded (see below) | +| `open-tasks.md` | `/ca:task` only | `/ca:status`, the statusline, `SessionStart` | No: guarded to `/ca:task` | | `open-questions.md` | the orchestrator, when a `[CONFIRM-NN]` is raised | `/ca:status`, `SessionStart`, the statusline | Yes | -| `decisions/*.md`, `decision-log.md` | `/ca:adr` only | `/ca:adr-status`, `/ca:reconcile`, `post-write-edit.py` (H-12) | No — guarded to `/ca:adr` | +| `decisions/*.md`, `decision-log.md` | `/ca:adr` only | `/ca:adr-status`, `/ca:reconcile`, `post-write-edit.py` (H-12) | No: guarded to `/ca:adr` | | `specs/*.md` | `brainstorming` skill (via `/ca:feature`, `/ca:sprint`) | `writing-plans`, `/ca:status` | Yes | | `plans/*.md` | `writing-plans` skill | `executing-plans`, `subagent-driven-development`, `/ca:status` | Yes | | `checkpoints/*.md` | `checkpoint-aggregator` agent (`/ca:checkpoint`) | `/ca:audit`, `/ca:status`-adjacent reads | Yes | | `audits/*.md` | `/ca:audit` | humans (report only) | Yes | | `reports//` | `/ca:tribunal` | `/ca:tribunal` on resume, the filing gate | Yes, but not while a run is in flight | -| `sprint-log.md` | `/ca:sprint` | `/ca:status`, `/ca:audit` | No — append-only, guarded (H-05) | -| `overrides.log` | `/ca:override`, `/ca:dev` entry/exit | statusline, `/ca:status`, `/ca:audit`, staleness-warn | No — append-only, guarded (H-05) | -| `triage.log` | `/ca:feature` small-lane triage | `/ca:metrics`, `/ca:audit` | No — append-only, guarded (H-05) | -| `gate-events.log` | every `block()`/`remind()`/`warn()` call in the hooks | (durable sink; no reader ships yet) | No — append-only, guarded (H-05) | -| `.markers/` | `security-pass.py`, `migration-pass.py`, `/ca:dev`, `/ca:adr` | the commit-gate hooks (`pre-bash.py`, `pre-write.py`, `pre-edit.py`) | No — guarded, and hand-writing one is friction and an audit trail, not a proof (see below) | -| `.provenance/*.json` | `context-creation`, `decompose`, `context-check` (re-scout/re-baseline) | `SessionStart` (drift line), `commit-gate` (auto-heal) | Not by hand — regenerate via `/ca:context-check` | +| `sprint-log.md` | `/ca:sprint` | `/ca:status`, `/ca:audit` | No: append-only, guarded (H-05) | +| `overrides.log` | `/ca:override`, `/ca:dev` entry/exit | statusline, `/ca:status`, `/ca:audit`, staleness-warn | No: append-only, guarded (H-05) | +| `triage.log` | `/ca:feature` small-lane triage | `/ca:metrics`, `/ca:audit` | No: append-only, guarded (H-05) | +| `gate-events.log` | every `block()`/`remind()`/`warn()` call in the hooks | (durable sink; no reader ships yet) | No: append-only, guarded (H-05) | +| `.markers/` | `security-pass.py`, `migration-pass.py`, `/ca:dev`, `/ca:adr` | the commit-gate hooks (`pre-bash.py`, `pre-write.py`, `pre-edit.py`) | No: guarded, and hand-writing one is friction and an audit trail, not a proof (see below) | +| `.provenance/*.json` | `context-creation`, `decompose`, `context-check` (re-scout/re-baseline) | `SessionStart` (drift line), `commit-gate` (auto-heal) | Not by hand: regenerate via `/ca:context-check` | | `security-controls.md`, `tech-stack.md`, `coding-standards.md` | `/ca:init` / `/ca:create-context` / `/ca:decompose`, kept current by hand or `/ca:context-check` | every reviewer agent, the crypto/secret gates | Yes | -| `last-checkpoint` | `checkpoint-aggregator` agent | `/ca:status`, the statusline (overrides-since-checkpoint) | Not normally — it's a counter, not a note | +| `last-checkpoint` | `checkpoint-aggregator` agent | `/ca:status`, the statusline (overrides-since-checkpoint) | Not normally: it's a counter, not a note | | `spikes/*.md` | `/ca:spike` | humans, `/ca:feature` (seeds `brainstorming`) | Yes | ## CONTEXT.md @@ -46,7 +46,7 @@ those are noted below. The activation contract. Its leading YAML frontmatter carries two load-bearing keys: - **`arbiter: enabled`** — the single flag every enforcement hook checks via `arbiter_active()` - (`plugins/ca/hooks/_hooklib.py`). It must appear inside a *properly closed* frontmatter block — + (`plugins/ca/hooks/_hooklib.py`). It must appear inside a *properly closed* frontmatter block: `---` opens on line 1, `arbiter: enabled` somewhere inside, `---` closes it. A block that opens but never closes is treated as **malformed**, not disabled, and is surfaced as an error rather than silently ignored. A file with no frontmatter at all is simply dormant. No frontmatter, @@ -75,35 +75,35 @@ frontmatter are both in place, the orchestrator persona loads on every session i **Readers:** every enforcement hook, the `SessionStart` injector, the statusline. **Editable by hand?** The prose body, yes. The frontmatter is guarded: a Write/Edit that would flip `arbiter: enabled` off, or corrupt the block, is blocked (`pre-write.py`/`pre-edit.py`, -issue #159) — the file that turns every gate off can't itself be edited past the gate. -**Delete it:** codeArbiter goes fully dormant in this repo on the next session — no persona, no -enforcement, nothing loads. The rest of `.codearbiter/` is untouched on disk; recreating +issue #159). The file that turns every gate off can't itself be edited past the gate. +**Delete it:** codeArbiter goes fully dormant in this repo on the next session (no persona, no +enforcement, nothing loads). The rest of `.codearbiter/` is untouched on disk; recreating `CONTEXT.md` (or running `/ca:init` again) reactivates it against whatever state remains. ## open-tasks.md -The [board](/glossary/#board): one top-level `- ` bullet per task, in one of three states — +The [board](/glossary/#board): one top-level `- ` bullet per task, in one of three states: queued (`- [ ]`), in-progress (`- [~]`, always carrying a started date), or done (`- [x]`). The only sanctioned writer is `/ca:task` (`add` / `start` / `done`), which calls the pure `_taskboardlib` transforms via `taskwrite.py` so every entry stays schema-conformant and every -transition dated — hand-editing risked malforming the schema, which is why the command exists. +transition dated. Hand-editing risked malforming the schema, which is why the command exists. A task's `[x]` done-flip, `[~]` start-flip, or new `[ ]` entry rides the work commit itself through commit-gate (ADR-0008); there's no separate lagging board-only PR. **Writers:** `/ca:task` only. **Readers:** `/ca:status`, the statusline, `SessionStart` (in-flight -count and staleness). **Editable by hand?** No — guarded to the one writer. +count and staleness). **Editable by hand?** No: guarded to the one writer. **Delete it:** the board is empty going forward; nothing else breaks, but every count that reads it (statusline, `/ca:status`) reports zero until tasks are re-added. ## open-questions.md -The record of unresolved `[CONFIRM-NN]` items — numbered placeholders for a question only the +The record of unresolved `[CONFIRM-NN]` items: numbered placeholders for a question only the user can answer, per the terminology lock in `ORCHESTRATOR.md` §0.1. An open `CONFIRM-NN` blocks stage promotion until it's resolved; it is never guessed at or resolved inside an ADR. The `SessionStart` hook and the statusline both count occurrences here. **Writers:** the orchestrator, when a skill surfaces a genuine unknown. **Readers:** `/ca:status`, -`SessionStart`, the statusline. **Editable by hand?** Yes — resolving a `CONFIRM-NN` is a prose +`SessionStart`, the statusline. **Editable by hand?** Yes: resolving a `CONFIRM-NN` is a prose edit recording the decision and its date. **Delete it:** any `[CONFIRM-NN]` count in the statusline or `/ca:status` reads zero; a future skill that needs to raise one recreates the file. @@ -115,12 +115,12 @@ entry. `/ca:adr` is the only sanctioned author; both the shell flank (redirects, targeting `decisions/`) and the Write/Edit flank are guarded (H-11) so an ADR can't be fabricated, edited after the fact, or slipped in outside the command. Status moves through `proposed -> accepted -> superseded | rejected`; a superseding decision never rewrites the prior -file — it appends a new numbered ADR whose own text names what it supersedes, and a new +file. It appends a new numbered ADR whose own text names what it supersedes, and a new `decision-log.md` entry does the same. **Writers:** `/ca:adr` (via the `decision-lifecycle` skill) only. **Readers:** `/ca:adr-status`, `/ca:reconcile`, and `post-write-edit.py`'s H-12 advisory (a file matching an ADR's `governs:` -glob gets a reminder on every future touch). **Editable by hand?** No — guarded. +glob gets a reminder on every future touch). **Editable by hand?** No: guarded. **Delete it:** the project's decision history is gone; `governs:` reminders for any ADR that used to cover a file stop firing, and `/ca:adr-status` has nothing to report. @@ -131,7 +131,7 @@ approved (`/ca:feature`, `/ca:sprint`). `writing-plans` reads an approved spec t plan, and `/ca:status` lists every slug here alongside its plan to show how far each pipeline got. **Writers:** `brainstorming`. **Readers:** `writing-plans`, `/ca:status`. -**Editable by hand?** Yes — specs are prose documents; edit for clarity, but avoid rewriting +**Editable by hand?** Yes. Specs are prose documents; edit for clarity, but avoid rewriting acceptance criteria the plan and its tests already trace against. **Delete it:** `/ca:status` no longer lists that pipeline; a plan that referenced the missing spec still runs (the plan is self-contained), but nothing can re-derive it from the (now-gone) spec. @@ -153,8 +153,8 @@ resumption point; `/ca:status` can no longer report that pipeline's progress. One dated report per `/ca:checkpoint` sweep (`YYYY-MM-DD.md`), written by the `checkpoint-aggregator` agent from the finding-triage and decision-challenger output. Findings are classified by severity; the ones blocking the current change are called out, the rest recorded -for later. `/ca:tribunal`'s deep-audit output is a distinct artifact (`reports//`, below) -— checkpoints are the lean, routine sweep. +for later. `/ca:tribunal`'s deep-audit output is a distinct artifact (`reports//`, below). +Checkpoints are the lean, routine sweep. **Writers:** `checkpoint-aggregator`. **Readers:** humans (the report is the deliverable); `/ca:audit` pulls still-open findings from the most recent one. **Editable by hand?** Yes, though @@ -165,7 +165,7 @@ recreates the directory and writes fresh. One dated governance packet per `/ca:audit` run (`YYYY-MM-DD.md`), assembling commits, overrides, ADRs, sprint auto-decisions, open questions, and open checkpoint findings for a given range into -one document. `/ca:audit` is read-only against everything it summarizes — it never mutates +one document. `/ca:audit` is read-only against everything it summarizes: it never mutates `overrides.log`, `decisions/`, or any of its other sources. **Writers:** `/ca:audit`. **Readers:** humans. **Editable by hand?** Yes. @@ -179,25 +179,25 @@ presence, since `/ca:audit` re-derives everything from the underlying logs and f reuses the same `run-id` (the date is the run's creation date and never changes on resume). Every lens's findings are written one file per finding, plus append-only triage/run logs, so an interrupted deep audit picks back up from disk instead of restarting. Tribunal writes are confined -to this directory until the filing gate — it never edits, refactors, or commits project code. +to this directory until the filing gate. It never edits, refactors, or commits project code. **Writers:** `/ca:tribunal` only. **Readers:** `/ca:tribunal` itself, on resume, and the filing gate that turns approved findings into GitHub issues. **Editable by hand?** Not while a run is -in flight — you'd desync the resumable state. **Delete it:** an in-flight tribunal run can no +in flight: you'd desync the resumable state. **Delete it:** an in-flight tribunal run can no longer resume and restarts from scratch on next invocation; completed runs' local record is gone -(filed GitHub issues, if any, are unaffected — they live on GitHub, not here). +(filed GitHub issues, if any, are unaffected: they live on GitHub, not here). ## sprint-log.md The append-only ledger of every SMARTS-scored auto-decision an autonomous `/ca:sprint` makes on a non-hard-gate point, each entry carrying a confidence flag (`low` entries are the ones worth -reviewing after the fact). Hard gates — security controls, auth/crypto/secrets, irreversible -operations, an unresolved `[CONFIRM-NN]`, a merge to default — are never auto-decided and never +reviewing after the fact). Hard gates (security controls, auth/crypto/secrets, irreversible +operations, an unresolved `[CONFIRM-NN]`, a merge to default) are never auto-decided and never appear here as a resolved decision; they stop and surface to the user instead. **Writers:** `/ca:sprint`. **Readers:** `/ca:status`, `/ca:audit`, the staleness-warn check (a sprint marked active for over 30 minutes with no matching log activity gets a WARN). -**Editable by hand?** No — one of the four append-only audit logs (H-05): a shell truncation/ +**Editable by hand?** No. One of the four append-only audit logs (H-05): a shell truncation/ rewrite aimed at it is blocked, and a Write/Edit must be a verifiable tail-anchored append, never an overwrite or an empty-`old_string` edit passed off as one. **Delete it:** the sprint decision trail for past runs is gone; a `/ca:sprint` in progress that @@ -213,10 +213,10 @@ rather than recording an empty `BY:` field. The statusline counts entries newer `last-checkpoint` marker as "overrides since last checkpoint." **Writers:** `/ca:override`, `/ca:dev` (entry/exit lines). **Readers:** statusline, `/ca:status`, -`/ca:audit`, the CONFIRM-09 staleness-warn check. **Editable by hand?** No — H-05 guarded, same as +`/ca:audit`, the CONFIRM-09 staleness-warn check. **Editable by hand?** No. H-05 guarded, same as `sprint-log.md`: append-only, tail-anchored edits only, no truncation or rewrite. -**Delete it:** the entire override history is gone — a genuine loss, since this is the one -mechanical record that a bypass happened at all. codeArbiter keeps functioning; the audit trail +**Delete it:** the entire override history is gone (a genuine loss, since this is the one +mechanical record that a bypass happened at all). codeArbiter keeps functioning; the audit trail does not recover. ## triage.log @@ -226,36 +226,36 @@ reasoning behind routing a change to the lighter lane instead of the full spec-t `/ca:metrics` reads it to compute the small-lane rate trend. **Writers:** `/ca:feature` (small-lane triage step). **Readers:** `/ca:metrics`, `/ca:audit`. -**Editable by hand?** No — H-05 guarded, same append-only contract as the other three logs. +**Editable by hand?** No: H-05 guarded, same append-only contract as the other three logs. **Delete it:** the small-lane classification history is gone; `/ca:metrics`' small-lane-rate trend has nothing to compare against until new entries accumulate. ## gate-events.log -The durable, mechanical sink every `block()`, `remind()`, and `warn()` call in the hooks appends -one line to — `[ISO-8601Z] KIND [tag] host= hook=