diff --git a/.codearbiter/gate-events.log b/.codearbiter/gate-events.log index 8d122ef1..c97e0bf3 100644 --- a/.codearbiter/gate-events.log +++ b/.codearbiter/gate-events.log @@ -974,3 +974,12 @@ 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. +[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/.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 b2686790..c1db1657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,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..fe429cca 100644 --- a/core/pysrc/_sloplib.py +++ b/core/pysrc/_sloplib.py @@ -65,12 +65,73 @@ 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(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 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 — + 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. + + 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 = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): - return True - idx = seg.find(d, idx + 1) + 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 @@ -84,15 +145,59 @@ 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] + # 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]: + # 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( + 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 return findings diff --git a/package.json b/package.json index 35a07264..4b7aac4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.29", + "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 3bafb183..ff00b18f 100644 --- a/plugins/ca-codex/CHANGELOG.md +++ b/plugins/ca-codex/CHANGELOG.md @@ -27,6 +27,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..fe429cca 100644 --- a/plugins/ca-codex/hooks/_sloplib.py +++ b/plugins/ca-codex/hooks/_sloplib.py @@ -65,12 +65,73 @@ 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(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 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 — + 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. + + 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 = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): - return True - idx = seg.find(d, idx + 1) + 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 @@ -84,15 +145,59 @@ 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] + # 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]: + # 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( + 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 return findings diff --git a/plugins/ca-pi/CHANGELOG.md b/plugins/ca-pi/CHANGELOG.md index c1da4dfa..9a77ac90 100644 --- a/plugins/ca-pi/CHANGELOG.md +++ b/plugins/ca-pi/CHANGELOG.md @@ -2,6 +2,17 @@ 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.29] - 2026-07-26 ### Added 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..fe429cca 100644 --- a/plugins/ca-pi/hooks/_sloplib.py +++ b/plugins/ca-pi/hooks/_sloplib.py @@ -65,12 +65,73 @@ 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(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 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 — + 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. + + 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 = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): - return True - idx = seg.find(d, idx + 1) + 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 @@ -84,15 +145,59 @@ 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] + # 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]: + # 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( + 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 return findings diff --git a/plugins/ca-pi/package.json b/plugins/ca-pi/package.json index 7a518fb3..21b2717c 100644 --- a/plugins/ca-pi/package.json +++ b/plugins/ca-pi/package.json @@ -1,6 +1,6 @@ { "name": "ca-pi", - "version": "0.1.29", + "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..fe429cca 100644 --- a/plugins/ca/hooks/_sloplib.py +++ b/plugins/ca/hooks/_sloplib.py @@ -65,12 +65,73 @@ 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(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 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 — + 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. + + 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 = seg.find(d) + idx = head.find(d) while idx != -1: - if _WORD_RE.search(seg[:idx]) and _WORD_RE.search(seg[idx + 1:]): - return True - idx = seg.find(d, idx + 1) + 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 @@ -84,15 +145,59 @@ 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] + # 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]: + # 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( + 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 return findings diff --git a/plugins/ca/hooks/tests/test_sloplib.py b/plugins/ca/hooks/tests/test_sloplib.py index 841a4444..426ff8fb 100644 --- a/plugins/ca/hooks/tests/test_sloplib.py +++ b/plugins/ca/hooks/tests/test_sloplib.py @@ -222,5 +222,113 @@ 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_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]) + if __name__ == "__main__": unittest.main()