From 17d34de7597915e2d0e35b33c959de4136c0e55b Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sun, 26 Jul 2026 09:42:38 -0400 Subject: [PATCH 1/2] fix(hooks): two enforcement papercuts - commit-graph and wrapped dashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are gates misreading text they were never meant to match, both were found by hitting them during the sprint, and both are shared-core changes projected to all three hosts. Grouped because they share the version bump: a fourth contended ca-pi number for two small independent fixes is churn. #485 - `git commit-graph` was gated as `git commit`. COMMIT_RE spelled the subcommand `commit\b`, and a word boundary sits between `commit` and `-`, so every `commit-*` verb matched. The annoyance was not the cost. The gate told the operator to run the crypto-compliance gate for a command that writes no objects, creates no commit, and can introduce neither a crypto nor a secret change - leaving two escapes, both wrong: record a security-gate pass that certifies nothing, or reach for `/ca:override`, which is precisely the override-invented- to-cover-a-coverage-hole that ADR-0022 and #308 exist to prevent. It also bites at the worst possible moment, since a stale commit-graph is what a batch of `--delete-branch` merges leaves behind - which is how it was found. The exclusion is an explicit ALLOW of the one verb proven safe, not an allowlist of gated ones, so the failure direction stays closed: a `commit-*` name the matcher has never seen is still gated. `commit-tree` deliberately stays in scope - it creates a commit object, and a crafted commit plus `update-ref` is a real path around H-01. AC-4 asked for an audit of the sibling matchers. `push` has no `push-*` subcommand to over-match onto, and `git add--interactive` genuinely does stage, so `add\b` reaching it is correct rather than a false positive. What the audit did surface is recorded in the code and knowingly NOT fixed: these matchers scan command TEXT, so a git command quoted inside another command's arguments is gated as if it were being run - a `grep "git add"` trips H-03, twice during this work. Exempting quoted occurrences would be a real bypass, not a loosening: `sh -c "git add ."` executes what it quotes. Telling the two apart needs actual shell tokenization, and over-blocking a mention is the fail-CLOSED side of that trade (ORCHESTRATOR §2). #484 - the prose-separator-dash detector read one line at a time. It required word characters on both sides of the dash ON THE SAME LINE, so a separator at a soft-wrap boundary scored zero in BOTH directions: the right-hand span on the next line (`...one of three states —` / `listed below.`) or the left-hand span on the previous one (`...the heavyweight audit` / `— checkpoints are the lean sweep.`). All three fixtures are real misses found by hand in the site's own `codearbiter-directory.md` while fixing #338's 118 flagged lines. It now scans a paragraph at a time. `_prose_only` still runs per line and before the join, so inline code, URLs, ranges and the definition-list lead-in are stripped exactly as they were, and each finding still carries the line of its own dash rather than the paragraph's. Joining stops at every block boundary - blank line, list item, heading, table row, thematic break, fence - and a table row is never joined at all, because lending a neighbouring row's words to a lone N/A marker is the exact thing the cell split exists to prevent. A range split BY the wrap (`12–` / `18`) is re-checked on the join, since the same-line exemption cannot reach it. Verification beyond the unit tests, which is where the risk actually lives for a detector this widely wired: the change is monotone by construction, and measured to be - across the 23 in-scope prose files, no finding the old detector reported was lost, and 19 were gained. Every one sampled is a genuine wrap separator. The blocking CI gate (`check_site_voice.py`) is still clean on all 36 authored site pages, and H-13 is a `remind()`, so the 19 are reminders on a doc edit rather than a new block on files like CHANGELOG.md. ca-pi advances 0.1.27 -> 0.1.30: #491 claims 0.1.28 and #493 claims 0.1.29, and two branches must never claim one ca-pi number. ca (2.9.1) and ca-codex (0.3.0) carry no tag yet, so their entries join the open sections. Closes #485 Closes #484 Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L --- .codearbiter/gate-events.log | 6 ++ .github/scripts/check_site_voice.py | 17 ++--- .github/scripts/test_hook_guards.py | 23 +++++++ CHANGELOG.md | 20 ++++++ core/pysrc/_bashguardlib.py | 35 +++++++++- core/pysrc/_sloplib.py | 87 ++++++++++++++++++++++-- package.json | 2 +- plugins/ca-codex/CHANGELOG.md | 4 ++ plugins/ca-codex/hooks/_bashguardlib.py | 35 +++++++++- plugins/ca-codex/hooks/_sloplib.py | 87 ++++++++++++++++++++++-- plugins/ca-pi/CHANGELOG.md | 12 ++++ plugins/ca-pi/hooks/_bashguardlib.py | 35 +++++++++- plugins/ca-pi/hooks/_sloplib.py | 87 ++++++++++++++++++++++-- plugins/ca-pi/package.json | 2 +- plugins/ca/hooks/_bashguardlib.py | 35 +++++++++- plugins/ca/hooks/_sloplib.py | 87 ++++++++++++++++++++++-- plugins/ca/hooks/tests/test_sloplib.py | 89 +++++++++++++++++++++++++ 17 files changed, 619 insertions(+), 44 deletions(-) diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log index 8d122ef1..c1a3e4b0 100644 --- a/.codearbiter/gate-events.log +++ b/.codearbiter/gate-events.log @@ -974,3 +974,9 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages [2026-07-20T05:54:34Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. [2026-07-20T05:54:34Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. [2026-07-20T05:54:34Z] BLOCK [H-05] host=claude hook=pre-bash.py | The .codearbiter audit logs (overrides.log, triage.log, sprint-log.md, gate-events.log) are append-only (ORCHESTRATOR §7). Truncating, overwriting, or deleting the audit trail is prohibited; append with '>>' only. +[2026-07-26T13:31:49Z] BLOCK [H-03] hook=pre-bash.py | Wildcard staging is prohibited — a directory ('\') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-26T13:32:16Z] BLOCK [H-03] hook=pre-bash.py | Wildcard staging is prohibited — a directory ('\') stages a non-explicit file set. Stage files explicitly, one path per file (commit-gate skill). +[2026-07-26T13:35:19Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T13:36:07Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T13:39:29Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T13:39:44Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. diff --git a/.github/scripts/check_site_voice.py b/.github/scripts/check_site_voice.py index fde82e1c..0b8a556f 100644 --- a/.github/scripts/check_site_voice.py +++ b/.github/scripts/check_site_voice.py @@ -31,16 +31,13 @@ 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. +The line-wrap gap this gate shipped with is closed (#484). The detector used to +require word characters on both sides of the dash ON THE SAME LINE, so a +separator that landed at a soft-wrap boundary was invisible to it, and three +such dashes had to be found by hand in `codearbiter-directory.md`. It now scans +a paragraph at a time and attributes each finding to the line holding its dash, +with joining stopped at every block boundary (blank line, list item, heading, +table row, thematic break, fence) so a wrap is never confused for one. Usage: python .github/scripts/check_site_voice.py # audit, exit 1 on findings diff --git a/.github/scripts/test_hook_guards.py b/.github/scripts/test_hook_guards.py index e05702ce..59786614 100644 --- a/.github/scripts/test_hook_guards.py +++ b/.github/scripts/test_hook_guards.py @@ -409,6 +409,29 @@ def main(): expect_block(fx, "git commit -m 'add hashing'", "H-09b", "H-09b block: crypto commit with no recorded pass") + # 6a-bis (#485): object-database maintenance is not a commit. In the + # exact state 6a just proved blocks a real commit, `git commit-graph` + # must pass -- it writes no objects, creates no commit, and can + # introduce neither a crypto nor a secret change. `commit\b` matched it + # anyway, because a word boundary sits between `commit` and `-`. The + # cost was not the annoyance: the gate told the operator to run the + # crypto-compliance gate for a command that touches no content, leaving + # only a pass that certifies nothing or an /ca:override invented to + # cover a coverage hole -- the habit ADR-0022 and #308 exist to prevent. + # It also bites at the worst moment, since a stale graph is what a batch + # of --delete-branch merges leaves behind. + expect_allow(fx, "git commit-graph write --reachable", + "#485 allow: git commit-graph is not git commit") + expect_allow(fx, "git commit-graph verify", + "#485 allow: commit-graph verify is read-only") + # The exclusion is exactly one proven-safe verb, so anything else in the + # `commit-*` space stays gated rather than failing open on a name this + # matcher has never seen. + expect_block(fx, "git commit-tree HEAD^{tree} -p HEAD", "H-09b", + "#485 block: commit-tree does create a commit object") + expect_block(fx, "git commit-graphish --sneak", "H-09b", + "#485 block: an unknown commit-* verb is still gated") + # 6b. legacy empty marker (the old `touch`) -> binding block. # An empty marker proves only that *something* passed recently; it # covers no lines, so the diff-bound check must reject it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8f3475..7aa72f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,26 @@ predate the plugin rewrite and are grouped by date. ### Fixed +- `git commit-graph` is no longer gated as though it were `git commit` (#485). + A word boundary sits between `commit` and `-`, so the H-09b/H-10b matcher took + every `commit-*` verb. The cost was not the annoyance: the gate told the + operator to run the crypto-compliance gate for a command that writes no + objects and creates no commit, leaving only a pass that certifies nothing or + an `/ca:override` invented to cover a coverage hole. It bit at the worst + moment too, since a stale commit-graph is exactly what a batch of + `--delete-branch` merges leaves behind. `commit-tree` stays in scope + deliberately: it creates a commit object, and a crafted commit plus + `update-ref` is a real path around H-01. Anything else in the `commit-*` space + is still gated, so an unfamiliar verb fails closed rather than open. +- The prose-separator-dash detector reads a paragraph at a time instead of a + line at a time (#484). It required word characters on both sides of the dash + ON THE SAME LINE, so a separator that landed at a soft-wrap boundary scored + zero in both directions - the right-hand span on the next line, or the + left-hand span on the previous one. Three real violations in the site's own + pages had to be found by hand for that reason. Joining stops at every block + boundary (blank line, list item, heading, table row, thematic break, fence), + and each finding still carries the line of its own dash. + - A secret in a farm plan no longer persists in the run's permanent receipt. `plan.meta` was serialized verbatim into `.farm/runs//farm-report.json` and its Markdown sibling, and the run-scoped change made that permanent: the diff --git a/core/pysrc/_bashguardlib.py b/core/pysrc/_bashguardlib.py index ba52b6e3..f5ea83ca 100644 --- a/core/pysrc/_bashguardlib.py +++ b/core/pysrc/_bashguardlib.py @@ -203,9 +203,42 @@ def _commit_no_verify_in_cluster(args): # `/ca:checkpoint)` made `git diff HEAD -- …` fatal ("outside repository"), # failing the H-09b scan CLOSED on a clean commit. ARGS = r"(?P(?:\"[^\"]*\"|'[^']*'|[^|;&])*)" -COMMIT_RE = re.compile(GIT + r"\s+commit\b" + ARGS) +# The `commit` SUBCOMMAND, not every verb starting with it (#485). A word +# boundary sits between `commit` and `-`, so a bare `commit\b` matched +# `git commit-graph write` and gated object-database maintenance as though it +# were a commit — pointing the operator at the crypto-compliance gate for a +# command that writes no objects and creates no commit, whose only escapes were +# a pass certifying nothing or an override covering a coverage hole (the habit +# ADR-0022 and #308 exist to prevent). It bit at the worst moment too: a stale +# graph is what a batch of `--delete-branch` merges leaves behind. +# +# The exclusion is an explicit ALLOW of one proven-safe verb rather than an +# allowlist of gated ones, so the failure direction stays closed — a `commit-*` +# name this matcher has never seen is still gated. `commit-tree` deliberately +# stays in scope: it creates a commit object, and a crafted commit plus +# `update-ref` is a real path around H-01. +COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])" +COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS) PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS) ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS) +# #485 AC-4, the audit of the sibling matchers. Neither needs the same +# treatment, and both were left alone deliberately: +# +# push — git has no `push-*` subcommand, so `push\b` has nothing to +# over-match onto. If one ever ships, it is gated until reviewed. +# add — `git add--interactive` is a real plumbing helper and it DOES stage, +# so `add\b` reaching it is correct, not a false positive. +# +# What the audit DID surface is a different shape, and it is knowingly not +# fixed here: these matchers scan the command TEXT, so a git command quoted +# inside another command's arguments is gated as if it were being run — a +# `grep "git add"` trips H-03. Skipping quoted occurrences would be wrong, not +# merely conservative: `sh -c "git add ."` executes what it quotes, so the +# exemption would be a real bypass of the staging gate. Distinguishing the two +# needs actual shell tokenization, which is a larger change with a worse +# failure direction; over-blocking a mention is the fail-CLOSED side of that +# trade (ORCHESTRATOR §2: security before velocity). Tracked separately rather +# than half-fixed. # reliability-004 (#190): GIT_C_DIR_RE previously matched `-C` ONLY as the # FIRST token after `git`, so a global option in front of it (`--no-pager`, # `-c k=v`, `--git-dir=…`) hid the -C target entirely and git_cwd() fell back diff --git a/core/pysrc/_sloplib.py b/core/pysrc/_sloplib.py index 413fe6e3..e2c49a44 100644 --- a/core/pysrc/_sloplib.py +++ b/core/pysrc/_sloplib.py @@ -65,12 +65,59 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" + return _separating_dash_in("", seg, "") + + +# Block openers: a line matching any of these starts a new markdown block, so +# the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one +# of these would invent findings a reader never sees as a single sentence. +_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s") +_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s") +_BLOCKQUOTE_RE = re.compile(r"^\s*>") +_THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$") +# A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the +# same-line exemption cannot reach it and it has to be re-checked on the join. +_RANGE_LEFT_RE = re.compile(r"\d\s*$") +_RANGE_RIGHT_RE = re.compile(r"^\s*\d") + + +def _starts_new_block(raw): + """True if `raw` opens a new markdown block rather than continuing the + previous line's paragraph. A `|` anywhere makes a line un-joinable: table + rows are scanned cell-by-cell, and joining one to its neighbour would give a + lone N/A marker the word context the cell split exists to deny it.""" + return bool(not raw.strip() + or "|" in raw + or _FENCE_RE.match(raw) + or _HEADING_RE.match(raw) + or _LIST_ITEM_RE.match(raw) + or _THEMATIC_RE.match(raw) + or _BLOCKQUOTE_RE.match(raw)) + + +def _separating_dash_in(prefix, head, rest): + """True if a dash INSIDE `head` joins two text spans, where its left context + may reach back into `prefix` and its right context forward into `rest`. + + #484: `_segment_separates` required word characters on both sides of the + dash on the SAME line, so a separator at a soft-wrap boundary scored zero — + in both directions. `…three states —` / `listed below.` has its right-hand + span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints + are the lean sweep.` has its left-hand span on the previous one. Both were + real, unreported VOICE.md violations in the site's own pages. + + Only dashes inside `head` are considered, so attribution stays on the line + that actually holds the dash (AC-1) no matter how wide the paragraph is.""" for d in _DASHES: - idx = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): + left = prefix + head[:idx] + right = head[idx + 1:] + " " + rest + if (_WORD_RE.search(left) and _WORD_RE.search(right) + and not (_RANGE_LEFT_RE.search(left) + and _RANGE_RIGHT_RE.search(right))): return True - idx = seg.find(d, idx + 1) + idx = head.find(d, idx + 1) return False @@ -84,15 +131,41 @@ def find_prose_separator_dashes(text): """ findings = [] in_fence = False - for i, raw in enumerate(text.splitlines(), start=1): + lines = text.splitlines() + # Each paragraph is scanned as a unit so a separator that lands at a + # soft-wrap boundary is visible (#484), while every finding is still + # attributed to the line holding its dash. `_prose_only` runs PER LINE and + # before the join, so inline code, URLs, ranges and the definition-list + # lead-in are stripped exactly as they were. + index = 0 + while index < len(lines): + raw = lines[index] if _FENCE_RE.match(raw): in_fence = not in_fence + index += 1 continue if in_fence: + index += 1 continue - prose = _prose_only(raw) - if any(_segment_separates(seg) for seg in prose.split("|")): - findings.append({"line": i, "context": raw.strip()}) + # The paragraph: this line, plus every following line that continues it. + group = [index] + following = index + 1 + while following < len(lines) and not _starts_new_block(lines[following]): + group.append(following) + following += 1 + prose = [_prose_only(lines[i]) for i in group] + for position, i in enumerate(group): + head = prose[position] + if "|" in lines[i]: + # A table row: keep the cell-by-cell scan, and never let a + # neighbouring row lend it context. + hit = any(_segment_separates(cell) for cell in head.split("|")) + else: + hit = _separating_dash_in(" ".join(prose[:position]), head, + " ".join(prose[position + 1:])) + if hit: + findings.append({"line": i + 1, "context": lines[i].strip()}) + index = following return findings diff --git a/package.json b/package.json index 0b2629c7..4b7aac4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.27", + "version": "0.1.30", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/plugins/ca-codex/CHANGELOG.md b/plugins/ca-codex/CHANGELOG.md index 9a55e34e..684837bb 100644 --- a/plugins/ca-codex/CHANGELOG.md +++ b/plugins/ca-codex/CHANGELOG.md @@ -20,6 +20,10 @@ All notable changes to the **ca-codex** plugin are recorded here. Format follows clear the intent is (ADR-0022). ### Fixed +- The shared guard and anti-slop libraries pick up the `git commit-graph` + mis-gating fix (#485) and the line-wrap-boundary detector fix (#484). Both are + core changes projected to this host unchanged. + - A session started inside a linked worktree no longer repoints the main repository's git-level enforcement at a worktree path that is pruned out from under it. The enforcer entry is shared through the git common dir, so an diff --git a/plugins/ca-codex/hooks/_bashguardlib.py b/plugins/ca-codex/hooks/_bashguardlib.py index ba52b6e3..f5ea83ca 100644 --- a/plugins/ca-codex/hooks/_bashguardlib.py +++ b/plugins/ca-codex/hooks/_bashguardlib.py @@ -203,9 +203,42 @@ def _commit_no_verify_in_cluster(args): # `/ca:checkpoint)` made `git diff HEAD -- …` fatal ("outside repository"), # failing the H-09b scan CLOSED on a clean commit. ARGS = r"(?P(?:\"[^\"]*\"|'[^']*'|[^|;&])*)" -COMMIT_RE = re.compile(GIT + r"\s+commit\b" + ARGS) +# The `commit` SUBCOMMAND, not every verb starting with it (#485). A word +# boundary sits between `commit` and `-`, so a bare `commit\b` matched +# `git commit-graph write` and gated object-database maintenance as though it +# were a commit — pointing the operator at the crypto-compliance gate for a +# command that writes no objects and creates no commit, whose only escapes were +# a pass certifying nothing or an override covering a coverage hole (the habit +# ADR-0022 and #308 exist to prevent). It bit at the worst moment too: a stale +# graph is what a batch of `--delete-branch` merges leaves behind. +# +# The exclusion is an explicit ALLOW of one proven-safe verb rather than an +# allowlist of gated ones, so the failure direction stays closed — a `commit-*` +# name this matcher has never seen is still gated. `commit-tree` deliberately +# stays in scope: it creates a commit object, and a crafted commit plus +# `update-ref` is a real path around H-01. +COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])" +COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS) PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS) ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS) +# #485 AC-4, the audit of the sibling matchers. Neither needs the same +# treatment, and both were left alone deliberately: +# +# push — git has no `push-*` subcommand, so `push\b` has nothing to +# over-match onto. If one ever ships, it is gated until reviewed. +# add — `git add--interactive` is a real plumbing helper and it DOES stage, +# so `add\b` reaching it is correct, not a false positive. +# +# What the audit DID surface is a different shape, and it is knowingly not +# fixed here: these matchers scan the command TEXT, so a git command quoted +# inside another command's arguments is gated as if it were being run — a +# `grep "git add"` trips H-03. Skipping quoted occurrences would be wrong, not +# merely conservative: `sh -c "git add ."` executes what it quotes, so the +# exemption would be a real bypass of the staging gate. Distinguishing the two +# needs actual shell tokenization, which is a larger change with a worse +# failure direction; over-blocking a mention is the fail-CLOSED side of that +# trade (ORCHESTRATOR §2: security before velocity). Tracked separately rather +# than half-fixed. # reliability-004 (#190): GIT_C_DIR_RE previously matched `-C` ONLY as the # FIRST token after `git`, so a global option in front of it (`--no-pager`, # `-c k=v`, `--git-dir=…`) hid the -C target entirely and git_cwd() fell back diff --git a/plugins/ca-codex/hooks/_sloplib.py b/plugins/ca-codex/hooks/_sloplib.py index 413fe6e3..e2c49a44 100644 --- a/plugins/ca-codex/hooks/_sloplib.py +++ b/plugins/ca-codex/hooks/_sloplib.py @@ -65,12 +65,59 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" + return _separating_dash_in("", seg, "") + + +# Block openers: a line matching any of these starts a new markdown block, so +# the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one +# of these would invent findings a reader never sees as a single sentence. +_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s") +_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s") +_BLOCKQUOTE_RE = re.compile(r"^\s*>") +_THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$") +# A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the +# same-line exemption cannot reach it and it has to be re-checked on the join. +_RANGE_LEFT_RE = re.compile(r"\d\s*$") +_RANGE_RIGHT_RE = re.compile(r"^\s*\d") + + +def _starts_new_block(raw): + """True if `raw` opens a new markdown block rather than continuing the + previous line's paragraph. A `|` anywhere makes a line un-joinable: table + rows are scanned cell-by-cell, and joining one to its neighbour would give a + lone N/A marker the word context the cell split exists to deny it.""" + return bool(not raw.strip() + or "|" in raw + or _FENCE_RE.match(raw) + or _HEADING_RE.match(raw) + or _LIST_ITEM_RE.match(raw) + or _THEMATIC_RE.match(raw) + or _BLOCKQUOTE_RE.match(raw)) + + +def _separating_dash_in(prefix, head, rest): + """True if a dash INSIDE `head` joins two text spans, where its left context + may reach back into `prefix` and its right context forward into `rest`. + + #484: `_segment_separates` required word characters on both sides of the + dash on the SAME line, so a separator at a soft-wrap boundary scored zero — + in both directions. `…three states —` / `listed below.` has its right-hand + span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints + are the lean sweep.` has its left-hand span on the previous one. Both were + real, unreported VOICE.md violations in the site's own pages. + + Only dashes inside `head` are considered, so attribution stays on the line + that actually holds the dash (AC-1) no matter how wide the paragraph is.""" for d in _DASHES: - idx = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): + left = prefix + head[:idx] + right = head[idx + 1:] + " " + rest + if (_WORD_RE.search(left) and _WORD_RE.search(right) + and not (_RANGE_LEFT_RE.search(left) + and _RANGE_RIGHT_RE.search(right))): return True - idx = seg.find(d, idx + 1) + idx = head.find(d, idx + 1) return False @@ -84,15 +131,41 @@ def find_prose_separator_dashes(text): """ findings = [] in_fence = False - for i, raw in enumerate(text.splitlines(), start=1): + lines = text.splitlines() + # Each paragraph is scanned as a unit so a separator that lands at a + # soft-wrap boundary is visible (#484), while every finding is still + # attributed to the line holding its dash. `_prose_only` runs PER LINE and + # before the join, so inline code, URLs, ranges and the definition-list + # lead-in are stripped exactly as they were. + index = 0 + while index < len(lines): + raw = lines[index] if _FENCE_RE.match(raw): in_fence = not in_fence + index += 1 continue if in_fence: + index += 1 continue - prose = _prose_only(raw) - if any(_segment_separates(seg) for seg in prose.split("|")): - findings.append({"line": i, "context": raw.strip()}) + # The paragraph: this line, plus every following line that continues it. + group = [index] + following = index + 1 + while following < len(lines) and not _starts_new_block(lines[following]): + group.append(following) + following += 1 + prose = [_prose_only(lines[i]) for i in group] + for position, i in enumerate(group): + head = prose[position] + if "|" in lines[i]: + # A table row: keep the cell-by-cell scan, and never let a + # neighbouring row lend it context. + hit = any(_segment_separates(cell) for cell in head.split("|")) + else: + hit = _separating_dash_in(" ".join(prose[:position]), head, + " ".join(prose[position + 1:])) + if hit: + findings.append({"line": i + 1, "context": lines[i].strip()}) + index = following return findings diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md index 1d00072d..987d1146 100644 --- a/plugins/ca-pi/CHANGELOG.md +++ b/plugins/ca-pi/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to `ca-pi` are documented in this file. +## [0.1.30] - 2026-07-26 + +### Fixed + +- The shared guard and anti-slop libraries pick up two enforcement papercuts. + `git commit-graph` is no longer gated as a commit - the H-09b/H-10b matcher + took every `commit-*` verb, so object-database maintenance was told to run the + crypto-compliance gate and the only escapes were a pass certifying nothing or + an override covering a coverage hole (#485). And the prose-separator-dash + detector now reads a paragraph at a time, so a separator at a soft-wrap + boundary is reported instead of scoring zero in both directions (#484). + ## [0.1.27] - 2026-07-26 ### Fixed diff --git a/plugins/ca-pi/hooks/_bashguardlib.py b/plugins/ca-pi/hooks/_bashguardlib.py index ba52b6e3..f5ea83ca 100644 --- a/plugins/ca-pi/hooks/_bashguardlib.py +++ b/plugins/ca-pi/hooks/_bashguardlib.py @@ -203,9 +203,42 @@ def _commit_no_verify_in_cluster(args): # `/ca:checkpoint)` made `git diff HEAD -- …` fatal ("outside repository"), # failing the H-09b scan CLOSED on a clean commit. ARGS = r"(?P(?:\"[^\"]*\"|'[^']*'|[^|;&])*)" -COMMIT_RE = re.compile(GIT + r"\s+commit\b" + ARGS) +# The `commit` SUBCOMMAND, not every verb starting with it (#485). A word +# boundary sits between `commit` and `-`, so a bare `commit\b` matched +# `git commit-graph write` and gated object-database maintenance as though it +# were a commit — pointing the operator at the crypto-compliance gate for a +# command that writes no objects and creates no commit, whose only escapes were +# a pass certifying nothing or an override covering a coverage hole (the habit +# ADR-0022 and #308 exist to prevent). It bit at the worst moment too: a stale +# graph is what a batch of `--delete-branch` merges leaves behind. +# +# The exclusion is an explicit ALLOW of one proven-safe verb rather than an +# allowlist of gated ones, so the failure direction stays closed — a `commit-*` +# name this matcher has never seen is still gated. `commit-tree` deliberately +# stays in scope: it creates a commit object, and a crafted commit plus +# `update-ref` is a real path around H-01. +COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])" +COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS) PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS) ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS) +# #485 AC-4, the audit of the sibling matchers. Neither needs the same +# treatment, and both were left alone deliberately: +# +# push — git has no `push-*` subcommand, so `push\b` has nothing to +# over-match onto. If one ever ships, it is gated until reviewed. +# add — `git add--interactive` is a real plumbing helper and it DOES stage, +# so `add\b` reaching it is correct, not a false positive. +# +# What the audit DID surface is a different shape, and it is knowingly not +# fixed here: these matchers scan the command TEXT, so a git command quoted +# inside another command's arguments is gated as if it were being run — a +# `grep "git add"` trips H-03. Skipping quoted occurrences would be wrong, not +# merely conservative: `sh -c "git add ."` executes what it quotes, so the +# exemption would be a real bypass of the staging gate. Distinguishing the two +# needs actual shell tokenization, which is a larger change with a worse +# failure direction; over-blocking a mention is the fail-CLOSED side of that +# trade (ORCHESTRATOR §2: security before velocity). Tracked separately rather +# than half-fixed. # reliability-004 (#190): GIT_C_DIR_RE previously matched `-C` ONLY as the # FIRST token after `git`, so a global option in front of it (`--no-pager`, # `-c k=v`, `--git-dir=…`) hid the -C target entirely and git_cwd() fell back diff --git a/plugins/ca-pi/hooks/_sloplib.py b/plugins/ca-pi/hooks/_sloplib.py index 413fe6e3..e2c49a44 100644 --- a/plugins/ca-pi/hooks/_sloplib.py +++ b/plugins/ca-pi/hooks/_sloplib.py @@ -65,12 +65,59 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" + return _separating_dash_in("", seg, "") + + +# Block openers: a line matching any of these starts a new markdown block, so +# the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one +# of these would invent findings a reader never sees as a single sentence. +_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s") +_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s") +_BLOCKQUOTE_RE = re.compile(r"^\s*>") +_THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$") +# A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the +# same-line exemption cannot reach it and it has to be re-checked on the join. +_RANGE_LEFT_RE = re.compile(r"\d\s*$") +_RANGE_RIGHT_RE = re.compile(r"^\s*\d") + + +def _starts_new_block(raw): + """True if `raw` opens a new markdown block rather than continuing the + previous line's paragraph. A `|` anywhere makes a line un-joinable: table + rows are scanned cell-by-cell, and joining one to its neighbour would give a + lone N/A marker the word context the cell split exists to deny it.""" + return bool(not raw.strip() + or "|" in raw + or _FENCE_RE.match(raw) + or _HEADING_RE.match(raw) + or _LIST_ITEM_RE.match(raw) + or _THEMATIC_RE.match(raw) + or _BLOCKQUOTE_RE.match(raw)) + + +def _separating_dash_in(prefix, head, rest): + """True if a dash INSIDE `head` joins two text spans, where its left context + may reach back into `prefix` and its right context forward into `rest`. + + #484: `_segment_separates` required word characters on both sides of the + dash on the SAME line, so a separator at a soft-wrap boundary scored zero — + in both directions. `…three states —` / `listed below.` has its right-hand + span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints + are the lean sweep.` has its left-hand span on the previous one. Both were + real, unreported VOICE.md violations in the site's own pages. + + Only dashes inside `head` are considered, so attribution stays on the line + that actually holds the dash (AC-1) no matter how wide the paragraph is.""" for d in _DASHES: - idx = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): + left = prefix + head[:idx] + right = head[idx + 1:] + " " + rest + if (_WORD_RE.search(left) and _WORD_RE.search(right) + and not (_RANGE_LEFT_RE.search(left) + and _RANGE_RIGHT_RE.search(right))): return True - idx = seg.find(d, idx + 1) + idx = head.find(d, idx + 1) return False @@ -84,15 +131,41 @@ def find_prose_separator_dashes(text): """ findings = [] in_fence = False - for i, raw in enumerate(text.splitlines(), start=1): + lines = text.splitlines() + # Each paragraph is scanned as a unit so a separator that lands at a + # soft-wrap boundary is visible (#484), while every finding is still + # attributed to the line holding its dash. `_prose_only` runs PER LINE and + # before the join, so inline code, URLs, ranges and the definition-list + # lead-in are stripped exactly as they were. + index = 0 + while index < len(lines): + raw = lines[index] if _FENCE_RE.match(raw): in_fence = not in_fence + index += 1 continue if in_fence: + index += 1 continue - prose = _prose_only(raw) - if any(_segment_separates(seg) for seg in prose.split("|")): - findings.append({"line": i, "context": raw.strip()}) + # The paragraph: this line, plus every following line that continues it. + group = [index] + following = index + 1 + while following < len(lines) and not _starts_new_block(lines[following]): + group.append(following) + following += 1 + prose = [_prose_only(lines[i]) for i in group] + for position, i in enumerate(group): + head = prose[position] + if "|" in lines[i]: + # A table row: keep the cell-by-cell scan, and never let a + # neighbouring row lend it context. + hit = any(_segment_separates(cell) for cell in head.split("|")) + else: + hit = _separating_dash_in(" ".join(prose[:position]), head, + " ".join(prose[position + 1:])) + if hit: + findings.append({"line": i + 1, "context": lines[i].strip()}) + index = following return findings diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json index 2a686e56..21b2717c 100644 --- a/plugins/ca-pi/package.json +++ b/plugins/ca-pi/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.27", + "version": "0.1.30", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/plugins/ca/hooks/_bashguardlib.py b/plugins/ca/hooks/_bashguardlib.py index ba52b6e3..f5ea83ca 100644 --- a/plugins/ca/hooks/_bashguardlib.py +++ b/plugins/ca/hooks/_bashguardlib.py @@ -203,9 +203,42 @@ def _commit_no_verify_in_cluster(args): # `/ca:checkpoint)` made `git diff HEAD -- …` fatal ("outside repository"), # failing the H-09b scan CLOSED on a clean commit. ARGS = r"(?P(?:\"[^\"]*\"|'[^']*'|[^|;&])*)" -COMMIT_RE = re.compile(GIT + r"\s+commit\b" + ARGS) +# The `commit` SUBCOMMAND, not every verb starting with it (#485). A word +# boundary sits between `commit` and `-`, so a bare `commit\b` matched +# `git commit-graph write` and gated object-database maintenance as though it +# were a commit — pointing the operator at the crypto-compliance gate for a +# command that writes no objects and creates no commit, whose only escapes were +# a pass certifying nothing or an override covering a coverage hole (the habit +# ADR-0022 and #308 exist to prevent). It bit at the worst moment too: a stale +# graph is what a batch of `--delete-branch` merges leaves behind. +# +# The exclusion is an explicit ALLOW of one proven-safe verb rather than an +# allowlist of gated ones, so the failure direction stays closed — a `commit-*` +# name this matcher has never seen is still gated. `commit-tree` deliberately +# stays in scope: it creates a commit object, and a crafted commit plus +# `update-ref` is a real path around H-01. +COMMIT_SUBCOMMAND = r"commit(?:-(?!graph(?![-\w]))[\w-]+)?(?![-\w])" +COMMIT_RE = re.compile(GIT + r"\s+" + COMMIT_SUBCOMMAND + ARGS) PUSH_RE = re.compile(GIT + r"\s+push\b" + ARGS) ADD_RE = re.compile(GIT + r"\s+add\b" + ARGS) +# #485 AC-4, the audit of the sibling matchers. Neither needs the same +# treatment, and both were left alone deliberately: +# +# push — git has no `push-*` subcommand, so `push\b` has nothing to +# over-match onto. If one ever ships, it is gated until reviewed. +# add — `git add--interactive` is a real plumbing helper and it DOES stage, +# so `add\b` reaching it is correct, not a false positive. +# +# What the audit DID surface is a different shape, and it is knowingly not +# fixed here: these matchers scan the command TEXT, so a git command quoted +# inside another command's arguments is gated as if it were being run — a +# `grep "git add"` trips H-03. Skipping quoted occurrences would be wrong, not +# merely conservative: `sh -c "git add ."` executes what it quotes, so the +# exemption would be a real bypass of the staging gate. Distinguishing the two +# needs actual shell tokenization, which is a larger change with a worse +# failure direction; over-blocking a mention is the fail-CLOSED side of that +# trade (ORCHESTRATOR §2: security before velocity). Tracked separately rather +# than half-fixed. # reliability-004 (#190): GIT_C_DIR_RE previously matched `-C` ONLY as the # FIRST token after `git`, so a global option in front of it (`--no-pager`, # `-c k=v`, `--git-dir=…`) hid the -C target entirely and git_cwd() fell back diff --git a/plugins/ca/hooks/_sloplib.py b/plugins/ca/hooks/_sloplib.py index 413fe6e3..e2c49a44 100644 --- a/plugins/ca/hooks/_sloplib.py +++ b/plugins/ca/hooks/_sloplib.py @@ -65,12 +65,59 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" + return _separating_dash_in("", seg, "") + + +# Block openers: a line matching any of these starts a new markdown block, so +# the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one +# of these would invent findings a reader never sees as a single sentence. +_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s") +_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s") +_BLOCKQUOTE_RE = re.compile(r"^\s*>") +_THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$") +# A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the +# same-line exemption cannot reach it and it has to be re-checked on the join. +_RANGE_LEFT_RE = re.compile(r"\d\s*$") +_RANGE_RIGHT_RE = re.compile(r"^\s*\d") + + +def _starts_new_block(raw): + """True if `raw` opens a new markdown block rather than continuing the + previous line's paragraph. A `|` anywhere makes a line un-joinable: table + rows are scanned cell-by-cell, and joining one to its neighbour would give a + lone N/A marker the word context the cell split exists to deny it.""" + return bool(not raw.strip() + or "|" in raw + or _FENCE_RE.match(raw) + or _HEADING_RE.match(raw) + or _LIST_ITEM_RE.match(raw) + or _THEMATIC_RE.match(raw) + or _BLOCKQUOTE_RE.match(raw)) + + +def _separating_dash_in(prefix, head, rest): + """True if a dash INSIDE `head` joins two text spans, where its left context + may reach back into `prefix` and its right context forward into `rest`. + + #484: `_segment_separates` required word characters on both sides of the + dash on the SAME line, so a separator at a soft-wrap boundary scored zero — + in both directions. `…three states —` / `listed below.` has its right-hand + span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints + are the lean sweep.` has its left-hand span on the previous one. Both were + real, unreported VOICE.md violations in the site's own pages. + + Only dashes inside `head` are considered, so attribution stays on the line + that actually holds the dash (AC-1) no matter how wide the paragraph is.""" for d in _DASHES: - idx = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): + left = prefix + head[:idx] + right = head[idx + 1:] + " " + rest + if (_WORD_RE.search(left) and _WORD_RE.search(right) + and not (_RANGE_LEFT_RE.search(left) + and _RANGE_RIGHT_RE.search(right))): return True - idx = seg.find(d, idx + 1) + idx = head.find(d, idx + 1) return False @@ -84,15 +131,41 @@ def find_prose_separator_dashes(text): """ findings = [] in_fence = False - for i, raw in enumerate(text.splitlines(), start=1): + lines = text.splitlines() + # Each paragraph is scanned as a unit so a separator that lands at a + # soft-wrap boundary is visible (#484), while every finding is still + # attributed to the line holding its dash. `_prose_only` runs PER LINE and + # before the join, so inline code, URLs, ranges and the definition-list + # lead-in are stripped exactly as they were. + index = 0 + while index < len(lines): + raw = lines[index] if _FENCE_RE.match(raw): in_fence = not in_fence + index += 1 continue if in_fence: + index += 1 continue - prose = _prose_only(raw) - if any(_segment_separates(seg) for seg in prose.split("|")): - findings.append({"line": i, "context": raw.strip()}) + # The paragraph: this line, plus every following line that continues it. + group = [index] + following = index + 1 + while following < len(lines) and not _starts_new_block(lines[following]): + group.append(following) + following += 1 + prose = [_prose_only(lines[i]) for i in group] + for position, i in enumerate(group): + head = prose[position] + if "|" in lines[i]: + # A table row: keep the cell-by-cell scan, and never let a + # neighbouring row lend it context. + hit = any(_segment_separates(cell) for cell in head.split("|")) + else: + hit = _separating_dash_in(" ".join(prose[:position]), head, + " ".join(prose[position + 1:])) + if hit: + findings.append({"line": i + 1, "context": lines[i].strip()}) + index = following return findings diff --git a/plugins/ca/hooks/tests/test_sloplib.py b/plugins/ca/hooks/tests/test_sloplib.py index 841a4444..15063cc4 100644 --- a/plugins/ca/hooks/tests/test_sloplib.py +++ b/plugins/ca/hooks/tests/test_sloplib.py @@ -222,5 +222,94 @@ def test_bold_mid_sentence_is_not_treated_as_a_definition(self): self.assertEqual(len(S.find_prose_separator_dashes(line)), 1) + +class TestWrappedSeparatorDashes(unittest.TestCase): + """#484: the detector was line-based, so `_segment_separates` required word + characters on both sides of the dash ON THE SAME LINE. A separator that + landed at a soft-wrap boundary was invisible, and a contributor who wrapped + a line could pass the gate without meaning to. + + All three fixtures below are REAL misses, found by hand in + site/src/content/docs/codearbiter-directory.md while fixing #338's 118 + flagged lines — VOICE.md violations a reader sees that the gate did not + report.""" + + def test_trailing_dash_before_a_wrap_is_reported(self): + text = ("The board: one top-level bullet per task, in one of three states —\n" + "listed below.\n") + findings = S.find_prose_separator_dashes(text) + self.assertEqual([f["line"] for f in findings], [1], + "the finding must carry the line of the DASH, not the wrap") + + def test_the_frontmatter_case_is_reported(self): + text = (" (`plugins/ca/hooks/_hooklib.py`). It must appear inside a " + "*properly closed* frontmatter block —\n" + "the loader stops at the first unterminated one.\n") + self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], [1]) + + def test_leading_dash_after_a_wrap_is_reported(self): + # The mirror image: the dash opens the continuation line, so its + # left-hand context is on the line BEFORE it. Attribution still belongs + # to the line holding the dash. + text = ("A tribunal is the heavyweight audit\n" + "— checkpoints are the lean, routine sweep.\n") + self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], [2]) + + # -- AC-2: joining must not invent findings across a block boundary -------- # + def test_no_join_across_a_blank_line(self): + text = f"Ends with a dash —\n\nA new paragraph starts here.\n" + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_no_join_across_a_list_item_boundary(self): + text = (f"- first item ends with a dash —\n" + "- second item is a different item\n") + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_no_join_across_a_heading(self): + text = f"A line ending in a dash —\n## A new section\n" + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_no_join_across_a_table_row(self): + text = (f"| cell | — |\n" + f"| next | row |\n") + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_no_join_across_a_fence_boundary(self): + text = (f"Prose ending in a dash —\n" + "```\n" + "code_line_with_words()\n" + "```\n") + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_no_join_across_a_thematic_break(self): + text = f"A line ending in a dash —\n---\nAfter the break.\n" + self.assertEqual(S.find_prose_separator_dashes(text), []) + + # -- AC-3: every existing exemption survives the join --------------------- # + def test_a_numeric_range_split_by_a_wrap_stays_exempt(self): + text = f"See pages 12–\n18 for the detail.\n" + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_inline_code_across_a_wrap_stays_exempt(self): + text = f"Write `a — b`\nliterally in the file.\n" + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_a_definition_dash_is_still_exempt_when_the_line_wraps(self): + text = (f"- **term** — the meaning of it,\n" + " continued on the next line.\n") + self.assertEqual(S.find_prose_separator_dashes(text), []) + + def test_a_real_separator_after_a_definition_lead_in_still_reports(self): + # The #338 regression guard, now across a wrap: only the definition dash + # is dropped, so a genuine separator later in the same paragraph is + # still found. + text = (f"- **The hooks** — `a.py`, `b.py` —\n" + " make zero calls.\n") + self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], [1]) + + def test_a_single_line_paragraph_behaves_exactly_as_before(self): + text = f"The gate blocks — the human resolves.\n" + self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], [1]) + if __name__ == "__main__": unittest.main() From fbff6bc80967cbf86eba6892e6bf48b47ddae853 Mon Sep 17 00:00:00 2001 From: SUaDtL Date: Sun, 26 Jul 2026 09:52:58 -0400 Subject: [PATCH 2/2] perf(hooks): make #484's paragraph scan linear, not quadratic The first cut of the paragraph join rebuilt the joined prefix and suffix strings for every line of every paragraph, so scanning one N-line paragraph did O(N^2) string construction. Measured on a single long paragraph: 45ms at 3000 lines and 481ms at 10000, against 4ms and 13ms for the old per-line scan. This runs on every write and edit through H-13, so that is the wrong direction to move a hook that was previously free. The fix is to pass the surrounding context PRE-REDUCED instead of as text. The dash test only ever asks two questions of the lines around it: is there a word character further out, and is the immediately adjacent text a digit (the numeric-range exemption). The first is two accumulating passes per paragraph; the second needs only the nearest neighbour. Now 4.8ms and 15.5ms - a 9x and 31x improvement, and linear. The accumulation deliberately spans the WHOLE paragraph rather than peeking at the neighbouring line, which would have been the tempting simplification. A line that is entirely inline code strips to whitespace under `_prose_only`, so the real continuation can sit two lines from the dash; a one-line lookahead misses it. `test_context_reaches_past_a_line_that_strips_to_nothing` pins exactly that, and mutating the accumulation to an adjacent-only lookahead kills it - so the test constrains the reduction rather than merely agreeing with it. Behaviour is unchanged, and proven so rather than assumed: across all 741 Markdown files in the repository the linear implementation and the quadratic one it replaces produce byte-identical findings - 4997 of them, zero mismatches. The site-voice gate stays clean on all 36 authored pages. Refs #484 Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L --- .codearbiter/gate-events.log | 3 ++ core/pysrc/_sloplib.py | 56 ++++++++++++++++++++------ plugins/ca-codex/hooks/_sloplib.py | 56 ++++++++++++++++++++------ plugins/ca-pi/hooks/_sloplib.py | 56 ++++++++++++++++++++------ plugins/ca/hooks/_sloplib.py | 56 ++++++++++++++++++++------ plugins/ca/hooks/tests/test_sloplib.py | 19 +++++++++ 6 files changed, 198 insertions(+), 48 deletions(-) diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log index c1a3e4b0..c97e0bf3 100644 --- a/.codearbiter/gate-events.log +++ b/.codearbiter/gate-events.log @@ -980,3 +980,6 @@ Claude-Session: https://claude.ai/code/session_015ZDVU1BzUqnnPVHbZ397bX') stages [2026-07-26T13:36:07Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_bashguardlib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. [2026-07-26T13:39:29Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. [2026-07-26T13:39:44Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T13:49:34Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T13:49:39Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. +[2026-07-26T13:49:48Z] REMIND [H-12] hook=post-write-edit.py | core/pysrc/_sloplib.py is governed by ADR-0011 (Multi-host support — third sibling plugin ca-codex via shared core + thin host adapters). If this change contradicts it, route to /ca:reconcile or /ca:adr — do not drift silently. diff --git a/core/pysrc/_sloplib.py b/core/pysrc/_sloplib.py index e2c49a44..fe429cca 100644 --- a/core/pysrc/_sloplib.py +++ b/core/pysrc/_sloplib.py @@ -65,7 +65,7 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" - return _separating_dash_in("", seg, "") + return _separating_dash_in(seg) # Block openers: a line matching any of these starts a new markdown block, so @@ -95,9 +95,11 @@ def _starts_new_block(raw): or _BLOCKQUOTE_RE.match(raw)) -def _separating_dash_in(prefix, head, rest): +def _separating_dash_in(head, *, words_before=False, words_after=False, + tail_before="", head_after=""): """True if a dash INSIDE `head` joins two text spans, where its left context - may reach back into `prefix` and its right context forward into `rest`. + may reach back into the preceding lines of its paragraph and its right + context forward into the following ones. #484: `_segment_separates` required word characters on both sides of the dash on the SAME line, so a separator at a soft-wrap boundary scored zero — @@ -107,16 +109,28 @@ def _separating_dash_in(prefix, head, rest): real, unreported VOICE.md violations in the site's own pages. Only dashes inside `head` are considered, so attribution stays on the line - that actually holds the dash (AC-1) no matter how wide the paragraph is.""" + that actually holds the dash (AC-1) no matter how wide the paragraph is. + + The surrounding context arrives PRE-REDUCED rather than as joined text: + `words_before`/`words_after` are booleans the caller accumulates once per + paragraph, and `tail_before`/`head_after` are only the nearest neighbours, + which is all the numeric-range adjacency check can see. Building the joined + strings here instead was quadratic in paragraph length - 14x slower at 3000 + lines, measured - and this runs on every write and edit through H-13.""" for d in _DASHES: idx = head.find(d) while idx != -1: - left = prefix + head[:idx] - right = head[idx + 1:] + " " + rest - if (_WORD_RE.search(left) and _WORD_RE.search(right) - and not (_RANGE_LEFT_RE.search(left) - and _RANGE_RIGHT_RE.search(right))): - return True + left, right = head[:idx], head[idx + 1:] + if ((words_before or _WORD_RE.search(left)) + and (_WORD_RE.search(right) or words_after)): + # A range split BY the wrap: the digit adjacency lives on the + # neighbouring line, so fall back to it only when this line + # contributes nothing on that side. + near_left = left if left.strip() else tail_before + near_right = right if right.strip() else head_after + if not (_RANGE_LEFT_RE.search(near_left) + and _RANGE_RIGHT_RE.search(near_right)): + return True idx = head.find(d, idx + 1) return False @@ -154,6 +168,18 @@ def find_prose_separator_dashes(text): group.append(following) following += 1 prose = [_prose_only(lines[i]) for i in group] + # Reduce the paragraph's context to what the dash test actually needs, + # in one pass each direction. `word_before[k]` is "some EARLIER line in + # this paragraph has a word character", `word_after[k]` the same looking + # forward. It has to accumulate over the whole group rather than peek at + # the neighbour: a line that is entirely inline code strips to + # whitespace, so the real continuation can sit two lines from the dash. + word_before = [False] * len(prose) + for k in range(1, len(prose)): + word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1])) + word_after = [False] * len(prose) + for k in range(len(prose) - 2, -1, -1): + word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1])) for position, i in enumerate(group): head = prose[position] if "|" in lines[i]: @@ -161,8 +187,14 @@ def find_prose_separator_dashes(text): # neighbouring row lend it context. hit = any(_segment_separates(cell) for cell in head.split("|")) else: - hit = _separating_dash_in(" ".join(prose[:position]), head, - " ".join(prose[position + 1:])) + hit = _separating_dash_in( + head, + words_before=word_before[position], + words_after=word_after[position], + tail_before=prose[position - 1] if position else "", + head_after=(prose[position + 1] + if position + 1 < len(prose) else ""), + ) if hit: findings.append({"line": i + 1, "context": lines[i].strip()}) index = following diff --git a/plugins/ca-codex/hooks/_sloplib.py b/plugins/ca-codex/hooks/_sloplib.py index e2c49a44..fe429cca 100644 --- a/plugins/ca-codex/hooks/_sloplib.py +++ b/plugins/ca-codex/hooks/_sloplib.py @@ -65,7 +65,7 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" - return _separating_dash_in("", seg, "") + return _separating_dash_in(seg) # Block openers: a line matching any of these starts a new markdown block, so @@ -95,9 +95,11 @@ def _starts_new_block(raw): or _BLOCKQUOTE_RE.match(raw)) -def _separating_dash_in(prefix, head, rest): +def _separating_dash_in(head, *, words_before=False, words_after=False, + tail_before="", head_after=""): """True if a dash INSIDE `head` joins two text spans, where its left context - may reach back into `prefix` and its right context forward into `rest`. + may reach back into the preceding lines of its paragraph and its right + context forward into the following ones. #484: `_segment_separates` required word characters on both sides of the dash on the SAME line, so a separator at a soft-wrap boundary scored zero — @@ -107,16 +109,28 @@ def _separating_dash_in(prefix, head, rest): real, unreported VOICE.md violations in the site's own pages. Only dashes inside `head` are considered, so attribution stays on the line - that actually holds the dash (AC-1) no matter how wide the paragraph is.""" + that actually holds the dash (AC-1) no matter how wide the paragraph is. + + The surrounding context arrives PRE-REDUCED rather than as joined text: + `words_before`/`words_after` are booleans the caller accumulates once per + paragraph, and `tail_before`/`head_after` are only the nearest neighbours, + which is all the numeric-range adjacency check can see. Building the joined + strings here instead was quadratic in paragraph length - 14x slower at 3000 + lines, measured - and this runs on every write and edit through H-13.""" for d in _DASHES: idx = head.find(d) while idx != -1: - left = prefix + head[:idx] - right = head[idx + 1:] + " " + rest - if (_WORD_RE.search(left) and _WORD_RE.search(right) - and not (_RANGE_LEFT_RE.search(left) - and _RANGE_RIGHT_RE.search(right))): - return True + left, right = head[:idx], head[idx + 1:] + if ((words_before or _WORD_RE.search(left)) + and (_WORD_RE.search(right) or words_after)): + # A range split BY the wrap: the digit adjacency lives on the + # neighbouring line, so fall back to it only when this line + # contributes nothing on that side. + near_left = left if left.strip() else tail_before + near_right = right if right.strip() else head_after + if not (_RANGE_LEFT_RE.search(near_left) + and _RANGE_RIGHT_RE.search(near_right)): + return True idx = head.find(d, idx + 1) return False @@ -154,6 +168,18 @@ def find_prose_separator_dashes(text): group.append(following) following += 1 prose = [_prose_only(lines[i]) for i in group] + # Reduce the paragraph's context to what the dash test actually needs, + # in one pass each direction. `word_before[k]` is "some EARLIER line in + # this paragraph has a word character", `word_after[k]` the same looking + # forward. It has to accumulate over the whole group rather than peek at + # the neighbour: a line that is entirely inline code strips to + # whitespace, so the real continuation can sit two lines from the dash. + word_before = [False] * len(prose) + for k in range(1, len(prose)): + word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1])) + word_after = [False] * len(prose) + for k in range(len(prose) - 2, -1, -1): + word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1])) for position, i in enumerate(group): head = prose[position] if "|" in lines[i]: @@ -161,8 +187,14 @@ def find_prose_separator_dashes(text): # neighbouring row lend it context. hit = any(_segment_separates(cell) for cell in head.split("|")) else: - hit = _separating_dash_in(" ".join(prose[:position]), head, - " ".join(prose[position + 1:])) + hit = _separating_dash_in( + head, + words_before=word_before[position], + words_after=word_after[position], + tail_before=prose[position - 1] if position else "", + head_after=(prose[position + 1] + if position + 1 < len(prose) else ""), + ) if hit: findings.append({"line": i + 1, "context": lines[i].strip()}) index = following diff --git a/plugins/ca-pi/hooks/_sloplib.py b/plugins/ca-pi/hooks/_sloplib.py index e2c49a44..fe429cca 100644 --- a/plugins/ca-pi/hooks/_sloplib.py +++ b/plugins/ca-pi/hooks/_sloplib.py @@ -65,7 +65,7 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" - return _separating_dash_in("", seg, "") + return _separating_dash_in(seg) # Block openers: a line matching any of these starts a new markdown block, so @@ -95,9 +95,11 @@ def _starts_new_block(raw): or _BLOCKQUOTE_RE.match(raw)) -def _separating_dash_in(prefix, head, rest): +def _separating_dash_in(head, *, words_before=False, words_after=False, + tail_before="", head_after=""): """True if a dash INSIDE `head` joins two text spans, where its left context - may reach back into `prefix` and its right context forward into `rest`. + may reach back into the preceding lines of its paragraph and its right + context forward into the following ones. #484: `_segment_separates` required word characters on both sides of the dash on the SAME line, so a separator at a soft-wrap boundary scored zero — @@ -107,16 +109,28 @@ def _separating_dash_in(prefix, head, rest): real, unreported VOICE.md violations in the site's own pages. Only dashes inside `head` are considered, so attribution stays on the line - that actually holds the dash (AC-1) no matter how wide the paragraph is.""" + that actually holds the dash (AC-1) no matter how wide the paragraph is. + + The surrounding context arrives PRE-REDUCED rather than as joined text: + `words_before`/`words_after` are booleans the caller accumulates once per + paragraph, and `tail_before`/`head_after` are only the nearest neighbours, + which is all the numeric-range adjacency check can see. Building the joined + strings here instead was quadratic in paragraph length - 14x slower at 3000 + lines, measured - and this runs on every write and edit through H-13.""" for d in _DASHES: idx = head.find(d) while idx != -1: - left = prefix + head[:idx] - right = head[idx + 1:] + " " + rest - if (_WORD_RE.search(left) and _WORD_RE.search(right) - and not (_RANGE_LEFT_RE.search(left) - and _RANGE_RIGHT_RE.search(right))): - return True + left, right = head[:idx], head[idx + 1:] + if ((words_before or _WORD_RE.search(left)) + and (_WORD_RE.search(right) or words_after)): + # A range split BY the wrap: the digit adjacency lives on the + # neighbouring line, so fall back to it only when this line + # contributes nothing on that side. + near_left = left if left.strip() else tail_before + near_right = right if right.strip() else head_after + if not (_RANGE_LEFT_RE.search(near_left) + and _RANGE_RIGHT_RE.search(near_right)): + return True idx = head.find(d, idx + 1) return False @@ -154,6 +168,18 @@ def find_prose_separator_dashes(text): group.append(following) following += 1 prose = [_prose_only(lines[i]) for i in group] + # Reduce the paragraph's context to what the dash test actually needs, + # in one pass each direction. `word_before[k]` is "some EARLIER line in + # this paragraph has a word character", `word_after[k]` the same looking + # forward. It has to accumulate over the whole group rather than peek at + # the neighbour: a line that is entirely inline code strips to + # whitespace, so the real continuation can sit two lines from the dash. + word_before = [False] * len(prose) + for k in range(1, len(prose)): + word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1])) + word_after = [False] * len(prose) + for k in range(len(prose) - 2, -1, -1): + word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1])) for position, i in enumerate(group): head = prose[position] if "|" in lines[i]: @@ -161,8 +187,14 @@ def find_prose_separator_dashes(text): # neighbouring row lend it context. hit = any(_segment_separates(cell) for cell in head.split("|")) else: - hit = _separating_dash_in(" ".join(prose[:position]), head, - " ".join(prose[position + 1:])) + hit = _separating_dash_in( + head, + words_before=word_before[position], + words_after=word_after[position], + tail_before=prose[position - 1] if position else "", + head_after=(prose[position + 1] + if position + 1 < len(prose) else ""), + ) if hit: findings.append({"line": i + 1, "context": lines[i].strip()}) index = following diff --git a/plugins/ca/hooks/_sloplib.py b/plugins/ca/hooks/_sloplib.py index e2c49a44..fe429cca 100644 --- a/plugins/ca/hooks/_sloplib.py +++ b/plugins/ca/hooks/_sloplib.py @@ -65,7 +65,7 @@ def _prose_only(line): def _segment_separates(seg): """True if `seg` contains an em/en dash with word characters on BOTH sides — i.e. it joins two text spans (a prose separator), not a lone filler dash.""" - return _separating_dash_in("", seg, "") + return _separating_dash_in(seg) # Block openers: a line matching any of these starts a new markdown block, so @@ -95,9 +95,11 @@ def _starts_new_block(raw): or _BLOCKQUOTE_RE.match(raw)) -def _separating_dash_in(prefix, head, rest): +def _separating_dash_in(head, *, words_before=False, words_after=False, + tail_before="", head_after=""): """True if a dash INSIDE `head` joins two text spans, where its left context - may reach back into `prefix` and its right context forward into `rest`. + may reach back into the preceding lines of its paragraph and its right + context forward into the following ones. #484: `_segment_separates` required word characters on both sides of the dash on the SAME line, so a separator at a soft-wrap boundary scored zero — @@ -107,16 +109,28 @@ def _separating_dash_in(prefix, head, rest): real, unreported VOICE.md violations in the site's own pages. Only dashes inside `head` are considered, so attribution stays on the line - that actually holds the dash (AC-1) no matter how wide the paragraph is.""" + that actually holds the dash (AC-1) no matter how wide the paragraph is. + + The surrounding context arrives PRE-REDUCED rather than as joined text: + `words_before`/`words_after` are booleans the caller accumulates once per + paragraph, and `tail_before`/`head_after` are only the nearest neighbours, + which is all the numeric-range adjacency check can see. Building the joined + strings here instead was quadratic in paragraph length - 14x slower at 3000 + lines, measured - and this runs on every write and edit through H-13.""" for d in _DASHES: idx = head.find(d) while idx != -1: - left = prefix + head[:idx] - right = head[idx + 1:] + " " + rest - if (_WORD_RE.search(left) and _WORD_RE.search(right) - and not (_RANGE_LEFT_RE.search(left) - and _RANGE_RIGHT_RE.search(right))): - return True + left, right = head[:idx], head[idx + 1:] + if ((words_before or _WORD_RE.search(left)) + and (_WORD_RE.search(right) or words_after)): + # A range split BY the wrap: the digit adjacency lives on the + # neighbouring line, so fall back to it only when this line + # contributes nothing on that side. + near_left = left if left.strip() else tail_before + near_right = right if right.strip() else head_after + if not (_RANGE_LEFT_RE.search(near_left) + and _RANGE_RIGHT_RE.search(near_right)): + return True idx = head.find(d, idx + 1) return False @@ -154,6 +168,18 @@ def find_prose_separator_dashes(text): group.append(following) following += 1 prose = [_prose_only(lines[i]) for i in group] + # Reduce the paragraph's context to what the dash test actually needs, + # in one pass each direction. `word_before[k]` is "some EARLIER line in + # this paragraph has a word character", `word_after[k]` the same looking + # forward. It has to accumulate over the whole group rather than peek at + # the neighbour: a line that is entirely inline code strips to + # whitespace, so the real continuation can sit two lines from the dash. + word_before = [False] * len(prose) + for k in range(1, len(prose)): + word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1])) + word_after = [False] * len(prose) + for k in range(len(prose) - 2, -1, -1): + word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1])) for position, i in enumerate(group): head = prose[position] if "|" in lines[i]: @@ -161,8 +187,14 @@ def find_prose_separator_dashes(text): # neighbouring row lend it context. hit = any(_segment_separates(cell) for cell in head.split("|")) else: - hit = _separating_dash_in(" ".join(prose[:position]), head, - " ".join(prose[position + 1:])) + hit = _separating_dash_in( + head, + words_before=word_before[position], + words_after=word_after[position], + tail_before=prose[position - 1] if position else "", + head_after=(prose[position + 1] + if position + 1 < len(prose) else ""), + ) if hit: findings.append({"line": i + 1, "context": lines[i].strip()}) index = following diff --git a/plugins/ca/hooks/tests/test_sloplib.py b/plugins/ca/hooks/tests/test_sloplib.py index 15063cc4..426ff8fb 100644 --- a/plugins/ca/hooks/tests/test_sloplib.py +++ b/plugins/ca/hooks/tests/test_sloplib.py @@ -307,6 +307,25 @@ def test_a_real_separator_after_a_definition_lead_in_still_reports(self): " make zero calls.\n") self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], [1]) + def test_context_reaches_past_a_line_that_strips_to_nothing(self): + # The paragraph context is reduced to booleans accumulated over the + # WHOLE group, not just the neighbouring line -- and it has to be. A line + # that is entirely inline code strips to whitespace, so the real + # continuation can sit two lines below the dash. A one-line lookahead + # would miss this, which makes it the case the linear reduction of the + # original quadratic join could most easily have broken. + text = ("Some text —\n" + "`code`\n" + "the real continuation.\n") + self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], + [1]) + + def test_a_range_split_by_the_wrap_stays_exempt_after_the_reduction(self): + # The digit adjacency lives on the neighbouring line, so the reduction + # keeps `tail_before`/`head_after` rather than only the booleans. + text = "See pages 12–\n18 for the detail.\n" + self.assertEqual(S.find_prose_separator_dashes(text), []) + def test_a_single_line_paragraph_behaves_exactly_as_before(self): text = f"The gate blocks — the human resolves.\n" self.assertEqual([f["line"] for f in S.find_prose_separator_dashes(text)], [1])