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
2 changes: 1 addition & 1 deletion OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ How this repository is run. It ships no application code, so its operations are

### Run the gates the way CI runs them

CI passes explicit `--check` lists, and a bare `python3 scripts/prose_lint.py [file]` runs `DEFAULT_RULES`, which omits `comment-wrap` and `comment-case`. A bare run therefore under-reports against what CI checks, and a clean result from it proves less than it appears to. Run the CI invocations:
CI passes explicit `--check` lists, and a bare `python3 scripts/prose_lint.py [file]` runs `DEFAULT_RULES`, which is those two lists together. What differs is the exit code rather than the coverage: CI gates on `charset`, `dupword` and `spelling` and reports the other five warn-only, where a bare run exits non-zero on any of the eight. `sentence-split` is in neither and is asked for by name. Run the CI invocations:

```sh
python3 scripts/test_prose_lint.py
Expand Down
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Each of these was checked line by line against the current tree and has nothing
The gates in [`scripts/`][scripts] are hub-only and are the fleet's main defense against agent-authored drift, so a gate that reports clean without having read anything is the worst failure available here. The `gh-write-guard` hook in [`host-setup/agent-safety/`][agent-safety] is grouped with them, since it is the same kind of instrument even though it ships per host rather than per repo. These entries are ordered so the scope floor lands before anything relies on a clean verdict.

- Make [`prose_lint.py`][prose-lint] assert a floor on its own scope, applying to itself the rule [`GOVERNANCE.md`][governance] already states: a gate that finds nothing is indistinguishable from a gate with nothing to find. A `--diff` run that resolves a non-empty diff and then matches **zero** files has almost certainly failed to scope rather than found a clean change, so it should say so instead of exiting 0. One session produced four separate routes to that same false clean: an unresolvable base widening to a whole-tree scan, a multi-line `paths` input read only to its first newline, a diff taken in one repository while scanning another, and a path under no repository at all. Each was fixed with its own guard, which is the wrong shape, because the fifth route will need a fifth guard and will be found the same way the first four were, by a reviewer rather than by the gate. A floor assertion covers the family. Note the honest limit before building it: a change touching only files the gate does not read (an image, a lock file) legitimately scopes to zero, so the assertion compares against the diff's own file list rather than against zero alone. This lands first, because every entry below it produces verdicts that are only worth reading once the gate can prove it read something.
- Clear the [#519][issue-519] prose backlog, starting with `catalog/snippets/**`. Two of the four changes that issue planned have already landed and its headline numbers are stale, so re-measure before quoting anything from it. `comment-wrap` and `comment-case` are now in `DEFAULT_RULES`, and `reports/` is now exempt as a generated tree, which is why the current figures are **534** whole-tree under the default rules and **184** under `catalog/snippets` alone rather than the 668 and 119 the issue records. The snippets lead the fix order for the reason the issue gives, that a non-conformant snippet seeds its violations into every repo that adopts it and the downstream repo is then flagged for content it was handed. What remains undecided is whether the gate itself becomes a carried file rather than a hub-only one, which is the same question the entry below on hub-only machinery asks from the other direction.
- Clear the [#519][issue-519] prose backlog, starting with `catalog/snippets/**`. Two of the four changes that issue planned have already landed and its headline numbers are stale, so re-measure before quoting anything from it. `comment-wrap` and `comment-case` are now in `DEFAULT_RULES`, and `reports/` is now exempt as a generated tree, which is why the current figures are **559** whole-tree under the default rules and **184** under `catalog/snippets` alone rather than the 668 and 119 the issue records. The whole-tree figure is the one that moves, having been 534 when this entry was written and 520 by the time [#570][issue-570] scoped the semicolon exemption to the sentence it lives in, which reported 44 semicolons the rule had been silent on. Re-measure it rather than quoting it, since a fix to the gate moves it as readily as a fix to the prose does. The snippets lead the fix order for the reason the issue gives, that a non-conformant snippet seeds its violations into every repo that adopts it and the downstream repo is then flagged for content it was handed. What remains undecided is whether the gate itself becomes a carried file rather than a hub-only one, which is the same question the entry below on hub-only machinery asks from the other direction.
- Gate the pattern-detectable half of the representative-data rule in [`prose_lint.py`][prose-lint], meaning an absolute home path (`/home/<name>`, `/Users/<name>`, `C:\Users\<name>`) or a bare drive letter sitting in committed prose, a code comment, or a fixture. [`GOVERNANCE.md`][governance] "Representative Data in Agent-Authored Text" states the rule and says why a check is a floor rather than an answer, so the check is introduced as covering the easy half or it gets read as closing the rule, which is the specific way it would make things worse. Three things to settle when writing it. The exemption carries the whole burden, since the rule's own wording, the `host-setup/` docs, and the audit's examples all quote path shapes in order to describe them, and a wrong exemption hands out a work list that damages correct documents. The leak that motivated the rule was in a pull request comment, which no committed-file linter reads at all, so say what surface the gate covers rather than letting its name imply the rule. And a home path in an operational repo's runbook may be the literal path an operator types, which is the repo's own content rather than an agent quoting the maintainer's environment, so decide whether the finding is scoped by file, by repo type, or left for the author to judge.
- Teach the `sha-pin` check in [`repo_gate.py`][repo-gate] to verify a pin **resolves**, not merely that it is shaped like a SHA. Forty hex characters is a format any fabricated string satisfies, and an agent hand-writing a plausible SHA into a workflow is a real failure mode rather than a hypothetical one. A resolvability check also catches the neighboring case, a pin whose commit was reachable only from a branch that has since been squashed and deleted, which breaks a downstream gate long after the change that caused it. Scope the network call to same-owner repositories, where the fleet's own actions live, and skip rather than fail when the host is offline so the local gate stays usable. Note that the existing `gh-write-guard` hook cannot cover this, since it watches Bash and an editor tool writing the same string into a file never reaches it.
- Add a check that a pull request's **description** does not contradict its own branch. Three stale descriptions in one session generated six review findings between them, each one a reviewer noticing that the body named a commit, a branch, or a behavior the branch no longer carried. The cheap and precise form is to extract SHAs and `uses:` refs quoted in the body and confirm each still appears in the head tree, since those are the claims that go stale silently and the ones a reviewer actually catches. Prose claims are out of scope, and deliberately so: judging those needs a similarity heuristic, which [`spec/section-model.md`][section-model] already rejects for exactly the reason it would fail here.
Expand Down Expand Up @@ -92,6 +92,7 @@ Everything here changes a file the fleet holds a copy of, so each entry costs a
[issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521
[issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523
[issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558
[issue-570]: https://github.com/ptr727/ProjectTemplate/issues/570

<!-- Pull requests -->

Expand Down
4 changes: 3 additions & 1 deletion scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ A double-quoted span in Markdown is treated as a quotation and not scanned for p

The `semicolon` and `dash` rules ban a construction rather than a detectable subset of it, so each flags by default and the exceptions are the ones the rule names: a semicolon inside a list that already carries commas, and for the dash a compound word, a leading list marker, a range, and the `- **Label** - explanation` separator that opens a governed bullet.

**The semicolon rule reads the list where it lives.** The comma qualifies the list as a whole rather than one separator's position, so an enumeration whose commas fall in a later item keeps every semicolon it carries. Reading it positionally split one series in two, flagging the openers of the same list it then exempted the tail of, which would have restructured the enumerated guarantees the exemption exists to protect. A Markdown table row is judged one cell at a time, since a row is a record of fields and a comma in one column cannot excuse a semicolon in another, and a bullet's `**Label**:` is dropped before the line is read, because it opens the bullet rather than announcing a list, the same construct the label dash is exempted for. What this misses is a sentence that reads as a list without being one: a colon early in a long line still excuses a splice later on it, which reading the diff catches.
**The semicolon rule reads the list where it lives.** The comma qualifies the list as a whole rather than one separator's position, so an enumeration whose commas fall in a later item keeps every semicolon it carries. Reading it positionally split one series in two, flagging the openers of the same list it then exempted the tail of, which would have restructured the enumerated guarantees the exemption exists to protect. A Markdown table row is judged one cell at a time, since a row is a record of fields and a comma in one column cannot excuse a semicolon in another, and a bullet's `**Label**:` is dropped before the line is read, because it opens the bullet rather than announcing a list, the same construct the label dash is exempted for. The colon is written inside the emphasis as often as outside it, so `**Label:**` is dropped on the same grounds, matching only one spelling having left the other announcing a list it never announced.

**The sentence is the unit the exemption is judged on, because that is where a list lives.** The whole bullet decided it once, so a colon anywhere before the first semicolon marked the bullet a list and exempted every semicolon after it, however plainly one joined two independent clauses, and the two did not have to be near each other or related at all. Measured over this repo when it was fixed, the exemption was covering 62 spans holding 120 semicolons across 9 files while the rule reported none of them, so the gate read as clean over the docs it exists to check. Scoping it to the sentence reported 43 further semicolons and silenced none, with a 44th from dropping the other spelling of the label colon, and the sentence boundary is the run-on rule's, so an initial or an abbreviation ends nothing and a terminator closing inside emphasis or a bracket (`.**`, `.)`) still ends a sentence. The colon arm was measured before being kept rather than dropped: dropping it flagged 14 further lines, and those were genuine colon-introduced lists whose items carry commas, which is the standard use the rule names.

**Both are Markdown-only for now.** A shell script carries 78 statement separators that are not prose at all, so telling a comment from code is a precondition for reaching source files. Until then a semicolon or dash in a code comment is missed, which reading the diff by eye still catches.

Expand Down
50 changes: 36 additions & 14 deletions scripts/prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,9 +605,29 @@ def strip_quoted(s: str) -> str:
return re.sub(r'"[^"\n]*"', '""', s)


# A bullet's `**Label**:` opens the text the same way `- **Label** -` does, so its colon
# introduces the bullet rather than a list, and reading it as one excused the splice after it.
LABEL_COLON = re.compile(r'^\s*(?:[-*]|[0-9]+\.)\s+\*\*[^*]+\*\*\s*:')
# A bullet's `**Label**:` opens the text the same way `- **Label** -` does.
# Its colon introduces the bullet rather than a list, and reading it as one excused the splice.
# The colon is written inside the emphasis as often as outside it, and both spell one construct.
# Matching `**Label**:` alone left `**Label:**` announcing a list it never announced.
LABEL_COLON = re.compile(r'^\s*(?:[-*]|[0-9]+\.)\s+\*\*[^*]+?(?:\*\*\s*:|:\s*\*\*)')

# A sentence boundary inside one line, so a list exemption is scoped to the sentence holding it.
# The guards are the run-on rule's, so an initial or an abbreviation ends nothing.
# The trailing class is the emphasis or bracket a Markdown sentence closes inside.
# Reading a bare `. ` instead left `.**` and `.)` joining a bullet's every sentence into one span.
SENTENCE_BREAK = re.compile(r'(?<!\b[A-Z])(?<!\be\.g)(?<!\bi\.e)(?<!\bvs)(?<!\betc)'
r'[.!?][*_`"\')\]]*\s+')


def sentences(span: str) -> list[str]:
"""The span split at its sentence boundaries, empty pieces dropped.

A list lives inside one sentence, so the sentence is the unit an exemption may be judged on.
Judged over a whole bullet instead, a colon anywhere before the first semicolon marked the
bullet a list and exempted every semicolon after it, however plainly one joined two clauses.
The colon and the semicolon did not have to be near each other, or related at all.
"""
return [s for s in SENTENCE_BREAK.split(span) if s.strip()]


def list_spans(s: str) -> list[str]:
Expand Down Expand Up @@ -890,17 +910,19 @@ def check_file(path: Path, rules: set[str]) -> list[tuple[int, str, str]]:
if path.suffix == '.md':
if 'semicolon' in rules:
for span in list_spans(prose):
# A list keeps its semicolons, announced by a colon or a second separator.
# The comma is a property of the list rather than of one separator's position,
# so an enumeration whose commas fall in a later item keeps every semicolon it
# carries. Reading it positionally split such a list, flagging the openers of
# the same series it then exempted the tail of.
listish = span.count(';') > 1 or ':' in span.split(';')[0]
if listish and ',' in span:
continue
for _ in SEMICOLON.finditer(span):
out.append((i, 'semicolon',
'semicolon in prose -> a comma or two sentences'))
# The sentence is the unit, since the list an exemption protects lives in one.
# Judged over a whole bullet, one colon exempted every semicolon after it.
for sentence in sentences(span):
# A list keeps its semicolons, announced by a colon or a second separator.
# The comma qualifies the list rather than one separator's position.
# An enumeration whose commas fall in a later item keeps every semicolon.
# Read positionally, it split one series and flagged that series' openers.
listish = sentence.count(';') > 1 or ':' in sentence.split(';')[0]
if listish and ',' in sentence:
continue
for _ in SEMICOLON.finditer(sentence):
out.append((i, 'semicolon',
'semicolon in prose -> a comma or two sentences'))
if 'dash' in rules:
skip = LABEL_DASH.match(prose)
for m in DASH.finditer(prose):
Expand Down
50 changes: 50 additions & 0 deletions scripts/test_prose_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,56 @@ def test_a_bullet_label_colon_does_not_announce_a_list(self) -> None:
self.kinds('- **Async**: avoid blocking calls; use await, always\n',
{'semicolon'}))

def test_a_colon_in_an_earlier_sentence_does_not_exempt_a_later_splice(self) -> None:
"""The exemption belongs to the sentence, not the bullet, and the two were unrelated.

Read over the whole bullet, an enumeration in one sentence excused every semicolon after
it, so the gate went silent across 120 semicolons in the docs it exists to check.
"""
self.assertEqual(['semicolon'], self.kinds(
'- **A rule.** The evidence is three things, and each matters: the first, the second, '
f'and the third. {SPLICE_BAIT}.\n', {'semicolon'}))

def test_a_sentence_closing_inside_emphasis_or_a_bracket_still_ends(self) -> None:
"""`.**` and `.)` end a sentence, and reading a bare `. ` joined a whole bullet into one."""
for opener in ('- **A label: with a list, of two.**',
'A label (with a list, of two.)'):
with self.subTest(opener=opener):
self.assertEqual(['semicolon'],
self.kinds(f'{opener} {SPLICE_BAIT}.\n', {'semicolon'}))

def test_a_series_in_one_sentence_does_not_exempt_the_next(self) -> None:
"""The second-separator arm is scoped the same way, a series belonging to its sentence."""
self.assertEqual(['semicolon'], self.kinds(
'It covers each target, and excludes the rest; it runs on push; it gates. '
f'{SPLICE_BAIT}.\n', {'semicolon'}))

def test_a_colon_introduced_list_whose_items_carry_commas_keeps_its_semicolon(self) -> None:
"""The colon arm earns its place: dropping it flagged this, the use the rule names.

Measured over the tree, dropping it reported 14 further lines, and the shapes below are
what they were, so the arm is scoped rather than removed.
"""
for text in (('Match the heading style: title case with short bind words (a, an, the, of); '
'hyphenated compounds capitalize both parts.\n'),
('- **Python** (the script profile): lint, format, and type check; '
'format-on-save and import organization via the formatter.\n')):
with self.subTest(text=text.split(':')[0]):
self.assertEqual([], self.kinds(text, {'semicolon'}))

def test_a_bullet_label_colon_inside_the_emphasis_is_the_same_opener(self) -> None:
"""`- **D3:**` and `- **D3**:` are one construct, and only one spelling was stripped."""
self.assertEqual(['semicolon'], self.kinds(
'- **D3:** each run builds one branch, so it classifies the version directly; the '
'gate literal, the expression, and the config all name the same branch.\n',
{'semicolon'}))

def test_an_abbreviation_does_not_end_a_sentence(self) -> None:
"""Splitting at `e.g.` cuts a list in half and flags the separator the exemption protects."""
self.assertEqual([], self.kinds(
'Pinned by path: a script, a hook (e.g. a shebang); vanilla files stay as they are.\n',
{'semicolon'}))

def test_prose_rules_do_not_reach_code_files(self) -> None:
"""A shell script carries statement separators, not prose, until comments can be extracted."""
for name in ('bait.sh', 'bait.py', 'bait.yml'):
Expand Down
Loading