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
118 changes: 118 additions & 0 deletions .github/scripts/check_site_voice.py
Original file line number Diff line number Diff line change
@@ -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())
22 changes: 22 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plugin>/tools/farm.js`, but the integration launcher ran `farm.ts`
Expand Down
49 changes: 46 additions & 3 deletions core/pysrc/_sloplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ca-pi",
"version": "0.1.25",
"version": "0.1.26",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
Expand Down
49 changes: 46 additions & 3 deletions plugins/ca-codex/hooks/_sloplib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions plugins/ca-pi/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading