@arbiterforge/ca-pi 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter - the H-09b/H-10b sensitive-line scan: crypto/TLS and secret
|
|
3
|
+
# detection, the pinned security diff argv, the path-aware diff walk, and the
|
|
4
|
+
# digests that bind a recorded gate pass to the exact lines it reviewed.
|
|
5
|
+
#
|
|
6
|
+
# Extracted from _hooklib (issue #321, architecture-002) as the first slice.
|
|
7
|
+
# Chosen first because it was the cleanest seam in the module, measured rather
|
|
8
|
+
# than guessed: the cluster referenced exactly ONE symbol from the rest of
|
|
9
|
+
# _hooklib (norm_path, now the _pathnorm floor), and NOTHING in the rest of
|
|
10
|
+
# _hooklib referenced the cluster. A one-way edge with no back-reference is
|
|
11
|
+
# what makes this safe to move without touching a single consumer.
|
|
12
|
+
#
|
|
13
|
+
# _hooklib re-exports every name below, so all 59 consuming files are
|
|
14
|
+
# unchanged and the pre-existing hook suites prove behavioural parity - which
|
|
15
|
+
# is the only proof that means anything for a refactor (the `refactor` skill's
|
|
16
|
+
# Phase 2 rule: parity comes from tests that did not move).
|
|
17
|
+
#
|
|
18
|
+
# WHY THESE BELONG TOGETHER: they are one concern with one failure mode. The
|
|
19
|
+
# regexes decide what counts as sensitive, the pinned argv decides what the
|
|
20
|
+
# diff even looks like, the walk decides which FILE a line belongs to, and the
|
|
21
|
+
# digests decide what a pass covered. #279 showed they fail as a unit: a
|
|
22
|
+
# greedy header parse in the walk un-exempted an unrelated file, and an
|
|
23
|
+
# unpinned prefix config silently re-opened it. Splitting them across modules
|
|
24
|
+
# would let one move without the others.
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
from _pathnorm import norm_path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Crypto/TLS and secret patterns — shared by the post-write reminder (H-09/H-10)
|
|
35
|
+
# and the blocking pre-commit gate (H-09b/H-10b) so the two never drift.
|
|
36
|
+
# Deliberately NOT matched: crypto.randomUUID / crypto.getRandomValues (benign
|
|
37
|
+
# ID generation tripped the gate on routine commits) — the bare `crypto\.`
|
|
38
|
+
# catch-all is narrowed to the members that actually sign, encrypt, derive
|
|
39
|
+
# keys, or produce security-relevant randomness. bcrypt stays: approved or
|
|
40
|
+
# not, a password-hashing change is exactly what crypto-compliance reviews.
|
|
41
|
+
CRYPTO_RE = re.compile(
|
|
42
|
+
r"(createHash|createCipher|createHmac|\bmd5\b|\bsha1\b|\brc4\b|\bdes\b|3des"
|
|
43
|
+
r"|\brc2\b|\bblowfish\b"
|
|
44
|
+
r"|\bRSA\b|x509|bcrypt"
|
|
45
|
+
r"|crypto\.(subtle|sign|verify|createSign|createVerify|generateKey"
|
|
46
|
+
r"|publicEncrypt|privateDecrypt|pbkdf2|scrypt|randomBytes|createDiffieHellman)"
|
|
47
|
+
r"|InsecureSkipVerify|verify=False"
|
|
48
|
+
# Node/TS TLS-disable forms — all networked first-party code here is TS, so
|
|
49
|
+
# this is where a verification bypass would actually land (2026-06-22 HIGH).
|
|
50
|
+
r"|rejectUnauthorized\s*[:=]\s*false|NODE_TLS_REJECT_UNAUTHORIZED)",
|
|
51
|
+
re.I,
|
|
52
|
+
)
|
|
53
|
+
# Two branches: (1) a secret keyword assigned a quoted literal, via `=` OR `:`
|
|
54
|
+
# (the colon/object form dominates this TS/JSON repo) — the quoted-value
|
|
55
|
+
# requirement keeps it from firing on every bare `token:` reference; (2) known
|
|
56
|
+
# high-entropy key prefixes, keyword-independent (AWS / GitHub / Anthropic).
|
|
57
|
+
# No LEADING word boundary on the keyword group (secrets-002): a `\b` there
|
|
58
|
+
# never fires when the keyword is the trailing segment of a compound identifier
|
|
59
|
+
# (the char left of `api_key` in `FARM_API_KEY` is `_`, a word char), so a
|
|
60
|
+
# hardcoded `FARM_API_KEY = "..."` silently passed the gate. The right-hand
|
|
61
|
+
# quoted-assignment anchor still bounds the match — a bare `token:` reference
|
|
62
|
+
# without a quoted value never matches.
|
|
63
|
+
SECRET_RE = re.compile(
|
|
64
|
+
r"(?:password|secret|token|api_key|apikey|private_key|passphrase|credential"
|
|
65
|
+
r"|aws_secret_access_key|client_secret)"
|
|
66
|
+
r"""["']?\s*[:=]\s*["'][^"']{4,}"""
|
|
67
|
+
r"|AKIA[0-9A-Z]{16}"
|
|
68
|
+
r"|ghp_[A-Za-z0-9]{36}"
|
|
69
|
+
r"|sk-ant-[A-Za-z0-9_-]{16,}",
|
|
70
|
+
re.I,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Sensitive-scan exemption (H-09b/H-10b, #279). gate-events.log is the durable
|
|
75
|
+
# BLOCK/REMIND/WARN sink block()/remind()/warn() append to (observability-001,
|
|
76
|
+
# #186) — it is machine-written, never source code, and structurally
|
|
77
|
+
# guaranteed to echo the crypto/secret detector's OWN message text back at
|
|
78
|
+
# itself the moment the gate ever fires a crypto/secret REMIND (e.g. "Crypto/
|
|
79
|
+
# TLS pattern detected" itself matches CRYPTO_RE). That makes it a permanent,
|
|
80
|
+
# self-perpetuating false positive with zero disclosure value: nothing
|
|
81
|
+
# written there is a genuine crypto/secret USE, only a report ABOUT one.
|
|
82
|
+
# Deliberately narrow — overrides.log/triage.log/sprint-log.md stay IN SCOPE:
|
|
83
|
+
# they carry human-written prose (an override reason, a triage note) that
|
|
84
|
+
# COULD legitimately contain a leaked secret worth catching. This set is
|
|
85
|
+
# anchored on the REPO-RELATIVE PATH a line belongs to, never a substring
|
|
86
|
+
# match on the line's own text — a secret cannot escape the scan by merely
|
|
87
|
+
# mentioning gate-events.log on its line; only lines that actually LIVE in
|
|
88
|
+
# that file are exempt.
|
|
89
|
+
SENSITIVE_SCAN_EXEMPT_RELPATHS = frozenset({".codearbiter/gate-events.log"})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_sensitive_scan_exempt(rel):
|
|
93
|
+
"""True iff `rel` (a repo-relative path, as attributed by `diff_added_
|
|
94
|
+
lines` or as returned by a bare `git ls-files` listing) names a file
|
|
95
|
+
exempt from the H-09b/H-10b crypto/secret scan. The ONE predicate both
|
|
96
|
+
the diff walk and the untracked/unborn-branch file listings route
|
|
97
|
+
through (#279 review LOW) — deliberately strict: no case-folding, no
|
|
98
|
+
`./`/`//` collapsing. An identifier that isn't an exact, `norm_path`'d
|
|
99
|
+
match resolves toward IN SCOPE (not exempt), which is the safe
|
|
100
|
+
direction. See SENSITIVE_SCAN_EXEMPT_RELPATHS."""
|
|
101
|
+
return norm_path(rel) in SENSITIVE_SCAN_EXEMPT_RELPATHS
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Every H-09b/H-10b sensitive-line reader (security-pass.py, pre-bash.py,
|
|
105
|
+
# git-enforce.py) MUST run `git diff` through this pinned argv, never a bare
|
|
106
|
+
# `["diff", ...]` (#279 review MEDIUM-1). Two independent user/global git
|
|
107
|
+
# config knobs change the destination-path prefix `diff_added_lines` below
|
|
108
|
+
# depends on: `diff.mnemonicPrefix=true` emits `c/`/`w/`/`i/`/`o/` instead of
|
|
109
|
+
# `a/`/`b/`, and `diff.noprefix=true` emits no prefix at all. Either one, left
|
|
110
|
+
# unpinned, silently un-exempts the REAL gate-events.log for any dev/CI runner
|
|
111
|
+
# with that config set — bringing back the exact self-DoS this whole change
|
|
112
|
+
# exists to close. `-c` overrides win over any config file (including repo,
|
|
113
|
+
# global, and system config), so pinning both flags to false here forces the
|
|
114
|
+
# standard `a/`/`b/` prefixes regardless of the caller's environment.
|
|
115
|
+
# `--no-ext-diff` additionally blocks a configured `GIT_EXTERNAL_DIFF` /
|
|
116
|
+
# `diff.external` from replacing git's own unified-diff output with something
|
|
117
|
+
# this parser was never designed to read. Centralized so a call site cannot
|
|
118
|
+
# forget to pin it (that was exactly how this hole would keep reopening).
|
|
119
|
+
SECURITY_DIFF_GIT_ARGS = (
|
|
120
|
+
"-c", "diff.mnemonicPrefix=false", "-c", "diff.noprefix=false",
|
|
121
|
+
"diff", "--no-ext-diff", "--src-prefix=a/", "--dst-prefix=b/",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# The fixed-width destination-path prefix `diff_added_lines` strips off a
|
|
125
|
+
# preamble `+++ ` line. MUST be a fixed-length slice, never a search for a
|
|
126
|
+
# separator: a greedy/`.+`-based parse of "diff --git a/<path> b/<path>" (the
|
|
127
|
+
# prior approach) resolves ambiguously when <path> itself contains " b/" —
|
|
128
|
+
# e.g. a real repo path `x b/.codearbiter/gate-events.log` renders that
|
|
129
|
+
# header as `diff --git a/x b/.codearbiter/gate-events.log b/x
|
|
130
|
+
# b/.codearbiter/gate-events.log`, and a greedy match backtracks group(1) to
|
|
131
|
+
# `.codearbiter/gate-events.log`, exempting the WHOLE unrelated source file
|
|
132
|
+
# (#279 review HIGH — reproduced end-to-end: an md5() call and a committed
|
|
133
|
+
# password both passed H-09b with no marker). Stripping a FIXED 6-character
|
|
134
|
+
# prefix has no such ambiguity: `"+++ b/x b/.codearbiter/gate-events.log"[6:]`
|
|
135
|
+
# is unconditionally `"x b/.codearbiter/gate-events.log"`, the correct full
|
|
136
|
+
# path, no matter what the path itself contains.
|
|
137
|
+
_PLUS_B_PREFIX = "+++ b/"
|
|
138
|
+
_PLUS_DEV_NULL = "+++ /dev/null"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def diff_added_lines(diff_text):
|
|
144
|
+
"""Added (`+`) lines of a unified `git diff`-style text (produced via
|
|
145
|
+
SECURITY_DIFF_GIT_ARGS — pinned `a/`/`b/` prefixes, no external diff), as
|
|
146
|
+
`(path, line)` tuples — a PATH-AWARE walk so a caller can exclude lines by
|
|
147
|
+
the FILE they live in, never by matching the line's own text (which a
|
|
148
|
+
hidden secret could otherwise dodge by naming the excluded file).
|
|
149
|
+
|
|
150
|
+
Two-phase state machine per file section, `path`/`in_hunk`/`seen_section`:
|
|
151
|
+
|
|
152
|
+
1. A bare, UNPREFIXED `diff ` line (`diff --git`, `diff --cc`, `diff
|
|
153
|
+
--combined`, ...) starts a new section: `path` resets to `None` and
|
|
154
|
+
`in_hunk` resets to False. Content can NEVER forge this line at column
|
|
155
|
+
0 — every diff-body line (context/added/removed) carries a leading
|
|
156
|
+
' '/'+'/'-'/'\\' character, so a file whose own content is literally
|
|
157
|
+
"diff --git a/x b/y" renders as "+diff --git a/x b/y" (added), never as
|
|
158
|
+
a bare match. Resetting `path` to None (not inheriting the PREVIOUS
|
|
159
|
+
section's path) on ANY `diff ` spelling matters for combined/merge
|
|
160
|
+
diffs (#279 review MEDIUM-2): git-enforce.py's `git diff --cached` at a
|
|
161
|
+
merge commit emits `diff --cc <path>` sections, which the prior
|
|
162
|
+
`diff --git`-only reset missed, letting a `--cc` section's added lines
|
|
163
|
+
silently inherit whatever path the section before it had — failing
|
|
164
|
+
toward EXEMPTION if that was gate-events.log.
|
|
165
|
+
2. While NOT yet `in_hunk` (the section's preamble, before its first `@@`
|
|
166
|
+
/ `@@@` hunk header), a `+++ b/<path>` line sets `path` by stripping
|
|
167
|
+
the FIXED 6-character prefix `_PLUS_B_PREFIX` — never a regex search
|
|
168
|
+
for a separator (see `_PLUS_B_PREFIX`'s comment for the ambiguity a
|
|
169
|
+
greedy `diff --git` parse had). `+++ /dev/null` (a deleted
|
|
170
|
+
destination) sets `path` back to None explicitly — no added lines are
|
|
171
|
+
expected in a deletion's hunk body anyway. Every other preamble line
|
|
172
|
+
(`--- a/<path>`, `index ...`, `new/deleted file mode`, `rename
|
|
173
|
+
from/to`, `similarity index`, `Binary files ... differ`) is inert
|
|
174
|
+
noise. A `+++ b/...`-shaped line can ONLY be trusted here, before the
|
|
175
|
+
section's first `@@`: once `in_hunk` is True, an apparently identical
|
|
176
|
+
`+++ b/...` string is body CONTENT (it carries the hunk body's own
|
|
177
|
+
leading `+`, i.e. the underlying source line was "++ b/..." or "+++
|
|
178
|
+
b/..." before diff-prefixing) and is captured as an added line like any
|
|
179
|
+
other, never re-parsed as an attribution header (closes the #279
|
|
180
|
+
review's own earlier finding: an added content line forging `+++
|
|
181
|
+
b/<path>` used to hijack attribution for the rest of the file).
|
|
182
|
+
3. Once `in_hunk`, `+`-prefixed lines are collected as `(path, content)`;
|
|
183
|
+
`-`/` `/`\\` (no-newline-marker) lines are skipped; anything else ends
|
|
184
|
+
the hunk (not producible by well-formed `git diff` output there, but
|
|
185
|
+
handled rather than guessed at).
|
|
186
|
+
|
|
187
|
+
FAILS SAFE: a `+` line seen before ANY `diff ` section header at all (not
|
|
188
|
+
producible by real `git diff` output) is attributed to `path=None` and
|
|
189
|
+
STILL COLLECTED, never silently dropped. `sensitive_scan_added_lines`
|
|
190
|
+
treats an unattributed (`None`) path as NOT exempt — in scope for
|
|
191
|
+
scanning. Exempting, or discarding, an unattributable line would be the
|
|
192
|
+
dangerous direction; over-scanning only risks a false positive, the
|
|
193
|
+
harmless failure mode here.
|
|
194
|
+
|
|
195
|
+
The shared primitive behind the H-09b/H-10b crypto/secret gate's producer
|
|
196
|
+
(security-pass.py) and both consumers (pre-bash.py, git-enforce.py) —
|
|
197
|
+
implemented once here so the gate-events.log exemption can never drift
|
|
198
|
+
between the three independent line-collectors that used to each do their
|
|
199
|
+
own flat `[ln[1:] for ln in text.splitlines() if ln.startswith("+") ...]`
|
|
200
|
+
walk with no path information (and no forgery-resistance) at all."""
|
|
201
|
+
path = None
|
|
202
|
+
in_hunk = False
|
|
203
|
+
seen_section = False # True once the first `diff ` section header is seen
|
|
204
|
+
out = []
|
|
205
|
+
for line in diff_text.splitlines():
|
|
206
|
+
if line.startswith("diff "):
|
|
207
|
+
path = None
|
|
208
|
+
in_hunk = False
|
|
209
|
+
seen_section = True
|
|
210
|
+
continue
|
|
211
|
+
if line.startswith("@@"):
|
|
212
|
+
in_hunk = True
|
|
213
|
+
continue
|
|
214
|
+
if not in_hunk:
|
|
215
|
+
if not seen_section:
|
|
216
|
+
# No `diff ` section header seen yet at all: not producible
|
|
217
|
+
# by real `git diff` output. Fail SAFE (see docstring) rather
|
|
218
|
+
# than silently dropping a line that cannot be confidently
|
|
219
|
+
# classed as header noise either.
|
|
220
|
+
if line.startswith("+"):
|
|
221
|
+
out.append((None, line[1:]))
|
|
222
|
+
continue
|
|
223
|
+
# Section preamble: only a genuine `+++ b/<path>` (or `+++
|
|
224
|
+
# /dev/null`) line here can set `path` — see point 2 above.
|
|
225
|
+
if line.startswith(_PLUS_B_PREFIX):
|
|
226
|
+
path = line[len(_PLUS_B_PREFIX):]
|
|
227
|
+
elif line == _PLUS_DEV_NULL:
|
|
228
|
+
path = None
|
|
229
|
+
continue
|
|
230
|
+
if line.startswith("+"):
|
|
231
|
+
out.append((path, line[1:]))
|
|
232
|
+
elif line.startswith(("-", " ", "\\")):
|
|
233
|
+
pass # removed / context / ""
|
|
234
|
+
else:
|
|
235
|
+
# Not producible by well-formed `git diff` output (a hunk body
|
|
236
|
+
# line is always one of the four prefixes above); treat it as the
|
|
237
|
+
# hunk having ended rather than guessing.
|
|
238
|
+
in_hunk = False
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def sensitive_scan_added_lines(diff_text):
|
|
243
|
+
"""`diff_added_lines(diff_text)` narrowed to the H-09b/H-10b crypto/secret
|
|
244
|
+
scan's candidate set: every added line EXCEPT those belonging to a
|
|
245
|
+
sensitive-scan-exempt path (currently only gate-events.log). Call this
|
|
246
|
+
instead of a raw `+`-line filter anywhere the crypto/secret scan reads a
|
|
247
|
+
diff, so the exemption is applied uniformly."""
|
|
248
|
+
return [ln for path, ln in diff_added_lines(diff_text)
|
|
249
|
+
if not (path and is_sensitive_scan_exempt(path))]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def line_digest(line):
|
|
253
|
+
"""Digest of one added diff line, for the security-gate binding
|
|
254
|
+
(H-09b/H-10b). The gate-pass marker stores these digests instead of being
|
|
255
|
+
an empty `touch`d file, so a recorded pass admits only the exact sensitive
|
|
256
|
+
lines it reviewed — not whatever lands in the next 30 minutes. Trailing
|
|
257
|
+
whitespace is stripped so CRLF translation between worktree and index
|
|
258
|
+
never breaks the match."""
|
|
259
|
+
return hashlib.sha256(line.rstrip().encode("utf-8", "replace")).hexdigest()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def content_digest(text):
|
|
263
|
+
"""Digest of a whole migration file's content, for the H-14 migration-gate
|
|
264
|
+
binding. Lines are rstripped and rejoined with \\n so CRLF translation
|
|
265
|
+
between worktree and index never breaks the match (same rationale as
|
|
266
|
+
line_digest). The producer (migration-pass.py) and the backstop
|
|
267
|
+
(pre-bash.py) both digest worktree content this way, so the two never
|
|
268
|
+
disagree on what a recorded pass covers."""
|
|
269
|
+
norm = "\n".join(line.rstrip() for line in text.splitlines())
|
|
270
|
+
return hashlib.sha256(norm.encode("utf-8", "replace")).hexdigest()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — statusline true-session-start resolver (extracted from
|
|
3
|
+
# statusline.py, architecture-004).
|
|
4
|
+
#
|
|
5
|
+
# Owns resolving the wall-clock session start from Claude Code's own session
|
|
6
|
+
# metadata (~/.claude/sessions/<pid>.json, matched on sessionId) — the same start
|
|
7
|
+
# time /usage reports, including idle/suspend gaps the current transcript alone
|
|
8
|
+
# can't show. The result is cached into the caller's ledger record so the O(N)
|
|
9
|
+
# directory scan runs at most once per session.
|
|
10
|
+
#
|
|
11
|
+
# Design principles (mirroring _ledgerlib.py):
|
|
12
|
+
# - Stdlib only; no third-party imports ever.
|
|
13
|
+
# - Zero side effects at import time.
|
|
14
|
+
# - Never raise on malformed input — degrade to None.
|
|
15
|
+
#
|
|
16
|
+
# Public API:
|
|
17
|
+
# session_start(sid, rec=None) -> float|None
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def num(x, default=0.0):
|
|
24
|
+
"""Coerce any host value to float; tolerate strings, None, and containers."""
|
|
25
|
+
try:
|
|
26
|
+
return float(x)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _session_start_scan(sid):
|
|
32
|
+
"""The O(N) fallback: scan ~/.claude/sessions/*.json for the one whose
|
|
33
|
+
sessionId matches `sid` and read its startedAt. The metadata file is named by
|
|
34
|
+
the host PID, not the sessionId, so a direct name lookup isn't possible from
|
|
35
|
+
here — a match-on-content scan is the only correct resolver. The caller caches
|
|
36
|
+
the result in the ledger so this scan runs at most once per session."""
|
|
37
|
+
d = os.path.join(os.path.expanduser("~"), ".claude", "sessions")
|
|
38
|
+
try:
|
|
39
|
+
names = os.listdir(d)
|
|
40
|
+
except OSError:
|
|
41
|
+
return None
|
|
42
|
+
for nm in names:
|
|
43
|
+
if not nm.endswith(".json"):
|
|
44
|
+
continue
|
|
45
|
+
fp = os.path.join(d, nm)
|
|
46
|
+
try:
|
|
47
|
+
if os.path.getsize(fp) > 65536: # metadata is <1KB; never read a large file
|
|
48
|
+
continue
|
|
49
|
+
with open(fp, encoding="utf-8") as f:
|
|
50
|
+
meta = json.load(f)
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
continue
|
|
53
|
+
if isinstance(meta, dict) and meta.get("sessionId") == sid:
|
|
54
|
+
sa = num(meta.get("startedAt"), None)
|
|
55
|
+
if sa:
|
|
56
|
+
return sa / 1000.0 if sa > 1e12 else sa # ms epoch -> seconds
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def session_start(sid, rec=None):
|
|
61
|
+
"""True session start (epoch seconds) from Claude Code's own session metadata
|
|
62
|
+
(~/.claude/sessions/<pid>.json, matched on sessionId). This is the wall-clock
|
|
63
|
+
start /usage reports, INCLUDING idle/suspend gaps the current transcript can't
|
|
64
|
+
show. None if unavailable -> caller falls back to the transcript.
|
|
65
|
+
|
|
66
|
+
Fast path: the resolved value is cached in the ledger record (`rec["sess_start"]`),
|
|
67
|
+
which ledger_update persists, so subsequent renders skip the per-render directory
|
|
68
|
+
scan entirely. On a cache miss the full scan runs once and seeds the cache."""
|
|
69
|
+
if not sid:
|
|
70
|
+
return None
|
|
71
|
+
if isinstance(rec, dict):
|
|
72
|
+
cached = num(rec.get("sess_start"), None)
|
|
73
|
+
if cached:
|
|
74
|
+
return cached
|
|
75
|
+
sa = _session_start_scan(sid)
|
|
76
|
+
if sa and isinstance(rec, dict):
|
|
77
|
+
rec["sess_start"] = sa # seed the ledger cache; ledger_update persists it
|
|
78
|
+
return sa
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# codeArbiter v2 — anti-slop copy-law detector (advisory).
|
|
2
|
+
#
|
|
3
|
+
# A lightweight guard for the single highest-signal AI tell: the em-dash / en-dash
|
|
4
|
+
# used as a PROSE sentence-separator (anti-slop-design core §3.A). It backs the
|
|
5
|
+
# PostToolUse reminder in post-write-edit.py and is the mechanical aid #60 asks
|
|
6
|
+
# for, so the PR #59 regression class (user-facing docs shipping with separator
|
|
7
|
+
# dashes) cannot recur silently.
|
|
8
|
+
#
|
|
9
|
+
# This is a heuristic, not a parser, and it is advisory — it nudges the producer
|
|
10
|
+
# to run the §3.A/§3.B copy self-audit; it never blocks. It honors the §3.A
|
|
11
|
+
# exemptions it can detect cheaply (fenced/inline code, URLs, numeric/date ranges)
|
|
12
|
+
# and errs toward silence on the rest.
|
|
13
|
+
#
|
|
14
|
+
# Public API:
|
|
15
|
+
# find_prose_separator_dashes(text) -> list[dict] findings per line; each
|
|
16
|
+
# {"line": int, "context": str}; empty when clean
|
|
17
|
+
# in_antislop_doc_scope(rel_path) -> bool True for user-facing Markdown the
|
|
18
|
+
# anti-slop bundle governs (repo-root + docs/**)
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
EM_DASH = "—"
|
|
23
|
+
EN_DASH = "–"
|
|
24
|
+
_DASHES = (EM_DASH, EN_DASH)
|
|
25
|
+
|
|
26
|
+
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
|
27
|
+
_INLINE_CODE_RE = re.compile(r"`[^`]*`")
|
|
28
|
+
# Strip URLs, autolinks/HTML tags and comments, and markdown link targets so a
|
|
29
|
+
# dash inside any of them is never read as prose.
|
|
30
|
+
_URL_RE = re.compile(r"https?://\S+|<[^>]*>|\]\([^)]*\)")
|
|
31
|
+
# Numeric / date range: a dash flanked by digits (pp. 12–18, 2019–2024). Correct
|
|
32
|
+
# typography per §3.A, never a finding.
|
|
33
|
+
_RANGE_RE = re.compile(r"\d\s*[–—]\s*\d")
|
|
34
|
+
# A letter or digit (Unicode), used to confirm a dash actually joins two text
|
|
35
|
+
# spans rather than standing alone (e.g. a lone "—" N/A marker in a table cell).
|
|
36
|
+
_WORD_RE = re.compile(r"[^\W_]", re.UNICODE)
|
|
37
|
+
# A DEFINITION-LIST dash: `- **term** — meaning`. Structural, not a sentence
|
|
38
|
+
# separator, and the rule this detector enforces says "sentence separators" -
|
|
39
|
+
# site/VOICE.md's own Terminology anchors section is written in exactly this
|
|
40
|
+
# form, so flagging it would make the gate contradict the style guide it exists
|
|
41
|
+
# to enforce (#338). Anchored to the start of the line and to a bolded lead-in,
|
|
42
|
+
# so an ordinary sentence that happens to contain bold text is untouched.
|
|
43
|
+
#
|
|
44
|
+
# The TERM is captured and kept; only the dash is dropped. Blanking the whole
|
|
45
|
+
# lead-in was the first cut and it was a false-negative generator: on
|
|
46
|
+
# - **The gate-enforcement hooks** — `a.py`, `b.py` — make zero calls.
|
|
47
|
+
# it removed the left-hand words, then _INLINE_CODE_RE blanked the backticks,
|
|
48
|
+
# and the SECOND dash - a real separator - was left with no word character on
|
|
49
|
+
# its left and escaped. That loosened the shared detector for docs/** and
|
|
50
|
+
# repo-root too, not only for site prose.
|
|
51
|
+
_DEFINITION_RE = re.compile(r"^(\s*(?:[-*+]\s+)?\*\*[^*]+\*\*\s*)[–—]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prose_only(line):
|
|
55
|
+
"""Drop the spans §3.A exempts so only candidate prose remains."""
|
|
56
|
+
# Only the definition DASH is dropped; the term is kept (group 1), so a
|
|
57
|
+
# later separator on the same line still has its left-hand context.
|
|
58
|
+
line = _DEFINITION_RE.sub(r"\1 ", line)
|
|
59
|
+
line = _INLINE_CODE_RE.sub(" ", line)
|
|
60
|
+
line = _URL_RE.sub(" ", line)
|
|
61
|
+
line = _RANGE_RE.sub(" ", line)
|
|
62
|
+
return line
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _segment_separates(seg):
|
|
66
|
+
"""True if `seg` contains an em/en dash with word characters on BOTH sides —
|
|
67
|
+
i.e. it joins two text spans (a prose separator), not a lone filler dash."""
|
|
68
|
+
return _separating_dash_in(seg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Block openers: a line matching any of these starts a new markdown block, so
|
|
72
|
+
# the line before it is NOT soft-wrapped into it (#484 AC-2). Joining across one
|
|
73
|
+
# of these would invent findings a reader never sees as a single sentence.
|
|
74
|
+
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s")
|
|
75
|
+
_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s")
|
|
76
|
+
_BLOCKQUOTE_RE = re.compile(r"^\s*>")
|
|
77
|
+
_THEMATIC_RE = re.compile(r"^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$")
|
|
78
|
+
# A range split BY the wrap: `12–` / `18`. _RANGE_RE only sees one line, so the
|
|
79
|
+
# same-line exemption cannot reach it and it has to be re-checked on the join.
|
|
80
|
+
_RANGE_LEFT_RE = re.compile(r"\d\s*$")
|
|
81
|
+
_RANGE_RIGHT_RE = re.compile(r"^\s*\d")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _starts_new_block(raw):
|
|
85
|
+
"""True if `raw` opens a new markdown block rather than continuing the
|
|
86
|
+
previous line's paragraph. A `|` anywhere makes a line un-joinable: table
|
|
87
|
+
rows are scanned cell-by-cell, and joining one to its neighbour would give a
|
|
88
|
+
lone N/A marker the word context the cell split exists to deny it."""
|
|
89
|
+
return bool(not raw.strip()
|
|
90
|
+
or "|" in raw
|
|
91
|
+
or _FENCE_RE.match(raw)
|
|
92
|
+
or _HEADING_RE.match(raw)
|
|
93
|
+
or _LIST_ITEM_RE.match(raw)
|
|
94
|
+
or _THEMATIC_RE.match(raw)
|
|
95
|
+
or _BLOCKQUOTE_RE.match(raw))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _separating_dash_in(head, *, words_before=False, words_after=False,
|
|
99
|
+
tail_before="", head_after=""):
|
|
100
|
+
"""True if a dash INSIDE `head` joins two text spans, where its left context
|
|
101
|
+
may reach back into the preceding lines of its paragraph and its right
|
|
102
|
+
context forward into the following ones.
|
|
103
|
+
|
|
104
|
+
#484: `_segment_separates` required word characters on both sides of the
|
|
105
|
+
dash on the SAME line, so a separator at a soft-wrap boundary scored zero —
|
|
106
|
+
in both directions. `…three states —` / `listed below.` has its right-hand
|
|
107
|
+
span on the next line; `A tribunal is the heavyweight audit` / `— checkpoints
|
|
108
|
+
are the lean sweep.` has its left-hand span on the previous one. Both were
|
|
109
|
+
real, unreported VOICE.md violations in the site's own pages.
|
|
110
|
+
|
|
111
|
+
Only dashes inside `head` are considered, so attribution stays on the line
|
|
112
|
+
that actually holds the dash (AC-1) no matter how wide the paragraph is.
|
|
113
|
+
|
|
114
|
+
The surrounding context arrives PRE-REDUCED rather than as joined text:
|
|
115
|
+
`words_before`/`words_after` are booleans the caller accumulates once per
|
|
116
|
+
paragraph, and `tail_before`/`head_after` are only the nearest neighbours,
|
|
117
|
+
which is all the numeric-range adjacency check can see. Building the joined
|
|
118
|
+
strings here instead was quadratic in paragraph length - 14x slower at 3000
|
|
119
|
+
lines, measured - and this runs on every write and edit through H-13."""
|
|
120
|
+
for d in _DASHES:
|
|
121
|
+
idx = head.find(d)
|
|
122
|
+
while idx != -1:
|
|
123
|
+
left, right = head[:idx], head[idx + 1:]
|
|
124
|
+
if ((words_before or _WORD_RE.search(left))
|
|
125
|
+
and (_WORD_RE.search(right) or words_after)):
|
|
126
|
+
# A range split BY the wrap: the digit adjacency lives on the
|
|
127
|
+
# neighbouring line, so fall back to it only when this line
|
|
128
|
+
# contributes nothing on that side.
|
|
129
|
+
near_left = left if left.strip() else tail_before
|
|
130
|
+
near_right = right if right.strip() else head_after
|
|
131
|
+
if not (_RANGE_LEFT_RE.search(near_left)
|
|
132
|
+
and _RANGE_RIGHT_RE.search(near_right)):
|
|
133
|
+
return True
|
|
134
|
+
idx = head.find(d, idx + 1)
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def find_prose_separator_dashes(text):
|
|
139
|
+
"""Return a finding per line that uses an em/en dash as a prose separator.
|
|
140
|
+
|
|
141
|
+
Each finding is {"line": <1-based int>, "context": <stripped line text>}.
|
|
142
|
+
Exempt: fenced code blocks, inline code, URLs, numeric/date ranges, and a
|
|
143
|
+
lone dash that joins no text (split on `|` so a table-cell N/A marker is not
|
|
144
|
+
mistaken for a separator).
|
|
145
|
+
"""
|
|
146
|
+
findings = []
|
|
147
|
+
in_fence = False
|
|
148
|
+
lines = text.splitlines()
|
|
149
|
+
# Each paragraph is scanned as a unit so a separator that lands at a
|
|
150
|
+
# soft-wrap boundary is visible (#484), while every finding is still
|
|
151
|
+
# attributed to the line holding its dash. `_prose_only` runs PER LINE and
|
|
152
|
+
# before the join, so inline code, URLs, ranges and the definition-list
|
|
153
|
+
# lead-in are stripped exactly as they were.
|
|
154
|
+
index = 0
|
|
155
|
+
while index < len(lines):
|
|
156
|
+
raw = lines[index]
|
|
157
|
+
if _FENCE_RE.match(raw):
|
|
158
|
+
in_fence = not in_fence
|
|
159
|
+
index += 1
|
|
160
|
+
continue
|
|
161
|
+
if in_fence:
|
|
162
|
+
index += 1
|
|
163
|
+
continue
|
|
164
|
+
# The paragraph: this line, plus every following line that continues it.
|
|
165
|
+
group = [index]
|
|
166
|
+
following = index + 1
|
|
167
|
+
while following < len(lines) and not _starts_new_block(lines[following]):
|
|
168
|
+
group.append(following)
|
|
169
|
+
following += 1
|
|
170
|
+
prose = [_prose_only(lines[i]) for i in group]
|
|
171
|
+
# Reduce the paragraph's context to what the dash test actually needs,
|
|
172
|
+
# in one pass each direction. `word_before[k]` is "some EARLIER line in
|
|
173
|
+
# this paragraph has a word character", `word_after[k]` the same looking
|
|
174
|
+
# forward. It has to accumulate over the whole group rather than peek at
|
|
175
|
+
# the neighbour: a line that is entirely inline code strips to
|
|
176
|
+
# whitespace, so the real continuation can sit two lines from the dash.
|
|
177
|
+
word_before = [False] * len(prose)
|
|
178
|
+
for k in range(1, len(prose)):
|
|
179
|
+
word_before[k] = word_before[k - 1] or bool(_WORD_RE.search(prose[k - 1]))
|
|
180
|
+
word_after = [False] * len(prose)
|
|
181
|
+
for k in range(len(prose) - 2, -1, -1):
|
|
182
|
+
word_after[k] = word_after[k + 1] or bool(_WORD_RE.search(prose[k + 1]))
|
|
183
|
+
for position, i in enumerate(group):
|
|
184
|
+
head = prose[position]
|
|
185
|
+
if "|" in lines[i]:
|
|
186
|
+
# A table row: keep the cell-by-cell scan, and never let a
|
|
187
|
+
# neighbouring row lend it context.
|
|
188
|
+
hit = any(_segment_separates(cell) for cell in head.split("|"))
|
|
189
|
+
else:
|
|
190
|
+
hit = _separating_dash_in(
|
|
191
|
+
head,
|
|
192
|
+
words_before=word_before[position],
|
|
193
|
+
words_after=word_after[position],
|
|
194
|
+
tail_before=prose[position - 1] if position else "",
|
|
195
|
+
head_after=(prose[position + 1]
|
|
196
|
+
if position + 1 < len(prose) else ""),
|
|
197
|
+
)
|
|
198
|
+
if hit:
|
|
199
|
+
findings.append({"line": i + 1, "context": lines[i].strip()})
|
|
200
|
+
index = following
|
|
201
|
+
return findings
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Authored site prose (#338). site/VOICE.md has banned em-dashes as sentence
|
|
205
|
+
# separators since 2026-07-02, and nothing enforced it: this predicate covered
|
|
206
|
+
# repo-root docs and docs/**, never site/. A rule with no gate, violated in 16
|
|
207
|
+
# of its own 36 authored files, is worse than no rule - reviewers cite it and it
|
|
208
|
+
# is wrong.
|
|
209
|
+
#
|
|
210
|
+
# Scope is AUTHORED prose only. Everything under content/docs/reference/ is
|
|
211
|
+
# generated on every build (91 of the 128 files there), changelog.md is a
|
|
212
|
+
# verbatim pass-through of the repo CHANGELOG, and site/src/curated/** mirrors
|
|
213
|
+
# plugins/ca bodies - which are framework prose under a different register, and
|
|
214
|
+
# already excluded via plugins/. Flagging any of those would report a finding
|
|
215
|
+
# nobody wrote and nobody can fix in place.
|
|
216
|
+
_SITE_PROSE_ROOT = "site/src/content/docs/"
|
|
217
|
+
_SITE_PROSE_EXCLUDED = ("reference/", "changelog.md")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def in_antislop_doc_scope(rel_path):
|
|
221
|
+
"""True for user-facing Markdown the anti-slop bundle governs: repo-root
|
|
222
|
+
community docs, docs/**, and AUTHORED site prose under
|
|
223
|
+
site/src/content/docs/ (#338). Excludes codeArbiter's own framework bodies
|
|
224
|
+
(everything under plugins/), machine-managed .codearbiter/ state, and every
|
|
225
|
+
generated site page."""
|
|
226
|
+
if not rel_path:
|
|
227
|
+
return False
|
|
228
|
+
p = rel_path.replace("\\", "/")
|
|
229
|
+
if p.startswith("./"):
|
|
230
|
+
p = p[2:]
|
|
231
|
+
# `.mdx` counts: the rule is about prose, not about a file extension, and
|
|
232
|
+
# two authored site pages are .mdx.
|
|
233
|
+
if not p.lower().endswith((".md", ".mdx")):
|
|
234
|
+
return False
|
|
235
|
+
if p.startswith("plugins/") or p.startswith(".codearbiter/"):
|
|
236
|
+
return False
|
|
237
|
+
if p.startswith(_SITE_PROSE_ROOT):
|
|
238
|
+
rest = p[len(_SITE_PROSE_ROOT):]
|
|
239
|
+
return not rest.startswith(_SITE_PROSE_EXCLUDED)
|
|
240
|
+
if p.startswith("site/"):
|
|
241
|
+
return False
|
|
242
|
+
if p.startswith("docs/"):
|
|
243
|
+
return True
|
|
244
|
+
return "/" not in p
|