|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# check-bridge-forbidden-phrases.sh |
| 4 | +# |
| 5 | +# Enforces the Echo Types / Ochrance bridge "forbidden moves" rules from |
| 6 | +# docs/ECHO-TYPES-OCHRANCE-BRIDGE.adoc on user-facing docs. |
| 7 | +# |
| 8 | +# The bridge is documentation-and-orientation-only — claiming production- |
| 9 | +# readiness, mechanised Lean→Rust correspondence, RMO beyond stubs, etc. |
| 10 | +# would misrepresent the project's status. This script greps for phrasing |
| 11 | +# that signals such over-claims. |
| 12 | +# |
| 13 | +# Scope is intentionally limited to *current* claim surfaces: |
| 14 | +# - README.adoc, EXPLAINME.adoc |
| 15 | +# - CHANGELOG.adoc (current section only) |
| 16 | +# - .machine_readable/*.contractile (except MUST + INTENT which NAME the |
| 17 | +# phrases to forbid them; see exempt-paths) |
| 18 | +# |
| 19 | +# Historical docs/*.md reports (PHASE_REPORT, CONTINUATION_REPORT, |
| 20 | +# SEAM_SEALING_REPORT, VERIFICATION_STRATEGY_ANALYSIS, ROADMAP_TO_V1) |
| 21 | +# are out of scope — they capture the state as of their author date. |
| 22 | +# |
| 23 | +# Negation context: a forbidden phrase is treated as OK if the surrounding |
| 24 | +# line contains a negation marker (NOT, not, No, no, never, neither, |
| 25 | +# isn't, doesn't, lacks, missing, "downgrade") within 80 characters either |
| 26 | +# side. This catches "**NOT production-ready**", "Q: Is this production- |
| 27 | +# ready? A: No.", "lacks production-ready X", etc. |
| 28 | +# |
| 29 | +# Run locally: bash .github/scripts/check-bridge-forbidden-phrases.sh |
| 30 | +# Exit codes: 0 = clean, 1 = forbidden phrase found, 2 = misconfig. |
| 31 | + |
| 32 | +set -euo pipefail |
| 33 | + |
| 34 | +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" |
| 35 | +cd "$ROOT" |
| 36 | + |
| 37 | +required=( |
| 38 | + "docs/ECHO-TYPES-OCHRANCE-BRIDGE.adoc" |
| 39 | + ".machine_readable/ECHO_TYPES_OCHRANCE_BRIDGE.a2ml" |
| 40 | +) |
| 41 | +for f in "${required[@]}"; do |
| 42 | + if [ ! -e "$f" ]; then |
| 43 | + echo "ERROR: required bridge artefact missing: $f" >&2 |
| 44 | + exit 2 |
| 45 | + fi |
| 46 | +done |
| 47 | + |
| 48 | +scan_globs=( |
| 49 | + "README.adoc" |
| 50 | + "EXPLAINME.adoc" |
| 51 | + "CHANGELOG.adoc" |
| 52 | +) |
| 53 | + |
| 54 | +# Files allowed to name the forbidden phrases verbatim (because they |
| 55 | +# forbid them, document them, or carry the policy itself). |
| 56 | +exempt=( |
| 57 | + "docs/ECHO-TYPES-OCHRANCE-BRIDGE.adoc" |
| 58 | + ".machine_readable/ECHO_TYPES_OCHRANCE_BRIDGE.a2ml" |
| 59 | + ".machine_readable/MUST.contractile" |
| 60 | + ".machine_readable/INTENT.contractile" |
| 61 | + ".github/scripts/check-bridge-forbidden-phrases.sh" |
| 62 | +) |
| 63 | + |
| 64 | +# Bridge sections inside README.adoc / EXPLAINME.adoc list forbidden |
| 65 | +# phrases as a teaching aid. We mark those line ranges as exempt by |
| 66 | +# detecting the section heading "Bridge to Echo Types + Ochrance" and |
| 67 | +# "Architectural Bridge" — a forbidden phrase inside that block is OK. |
| 68 | +BRIDGE_SECTION_RE='(== Bridge to Echo Types|== Architectural Bridge|=== Forbidden moves)' |
| 69 | + |
| 70 | +forbidden=( |
| 71 | + "production-ready" |
| 72 | + "production grade" |
| 73 | + "production-grade" |
| 74 | + "mechanised lean-to-rust" |
| 75 | + "mechanized lean-to-rust" |
| 76 | + "mechanised lean→rust" |
| 77 | + "mechanized lean→rust" |
| 78 | + "fully verified" |
| 79 | + "echo types proves valence-shell" |
| 80 | + "ochrance cryptographic integrity" |
| 81 | +) |
| 82 | + |
| 83 | +is_exempt_file() { |
| 84 | + local p="$1" |
| 85 | + for e in "${exempt[@]}"; do |
| 86 | + [ "$p" = "$e" ] && return 0 |
| 87 | + done |
| 88 | + return 1 |
| 89 | +} |
| 90 | + |
| 91 | +# Build a list of (file, start_line, end_line) tuples covering bridge |
| 92 | +# sections. We use awk to find heading offsets then emit ranges. |
| 93 | +bridge_ranges_for() { |
| 94 | + local file="$1" |
| 95 | + [ -f "$file" ] || return 0 |
| 96 | + awk -v re="$BRIDGE_SECTION_RE" ' |
| 97 | + /^==[^=]/ { |
| 98 | + # New top-level section — close any open bridge range. |
| 99 | + if (in_bridge) { |
| 100 | + printf "%d %d\n", start, NR - 1 |
| 101 | + in_bridge = 0 |
| 102 | + } |
| 103 | + if ($0 ~ re) { |
| 104 | + in_bridge = 1 |
| 105 | + start = NR |
| 106 | + } |
| 107 | + } |
| 108 | + /^===[^=]/ { |
| 109 | + if ($0 ~ re && !in_bridge) { |
| 110 | + in_bridge = 1 |
| 111 | + start = NR |
| 112 | + } |
| 113 | + } |
| 114 | + END { |
| 115 | + if (in_bridge) printf "%d %d\n", start, NR |
| 116 | + } |
| 117 | + ' "$file" |
| 118 | +} |
| 119 | + |
| 120 | +in_bridge_range() { |
| 121 | + local file="$1" |
| 122 | + local line="$2" |
| 123 | + while read -r start end; do |
| 124 | + if [ "$line" -ge "$start" ] && [ "$line" -le "$end" ]; then |
| 125 | + return 0 |
| 126 | + fi |
| 127 | + done < <(bridge_ranges_for "$file") |
| 128 | + return 1 |
| 129 | +} |
| 130 | + |
| 131 | +# Negation / future-aspiration context check. |
| 132 | +# Looks at the full line for markers indicating the phrase is being |
| 133 | +# negated, talked about historically, or named as a future target. |
| 134 | +has_negation_context() { |
| 135 | + local content="$1" |
| 136 | + local lower |
| 137 | + lower=$(printf '%s' "$content" | tr '[:upper:]' '[:lower:]') |
| 138 | + # Standalone "no"/"not"/etc as a word. |
| 139 | + if printf '%s' "$lower" | grep -qE "(^|[^[:alnum:]'])(not|no|never|neither|isn'?t|doesn'?t|don'?t|lacks?|missing|downgrade|forbid|caveat|≠|stub|stubs|aspirational)([^[:alnum:]]|$)"; then |
| 140 | + return 0 |
| 141 | + fi |
| 142 | + # Forward-looking targets ("future", "target", "goal", "aim", "roadmap", |
| 143 | + # "(future)") are not present-tense claims. |
| 144 | + if printf '%s' "$lower" | grep -qE "(^|[^[:alnum:]])(future|target|goal|aim|aiming|roadmap|aspiration|aspire|when|once|after)([^[:alnum:]]|$)"; then |
| 145 | + return 0 |
| 146 | + fi |
| 147 | + # "is NOT" / "are NOT" framing. |
| 148 | + if printf '%s' "$lower" | grep -qE "(is|are|was|were|will be|would be)[[:space:]]+not[[:space:]]"; then |
| 149 | + return 0 |
| 150 | + fi |
| 151 | + return 1 |
| 152 | +} |
| 153 | + |
| 154 | +failures=0 |
| 155 | +shopt -s nullglob |
| 156 | +for glob in "${scan_globs[@]}"; do |
| 157 | + for file in $glob; do |
| 158 | + is_exempt_file "$file" && continue |
| 159 | + [ -f "$file" ] || continue |
| 160 | + for phrase in "${forbidden[@]}"; do |
| 161 | + while IFS=: read -r lineno content; do |
| 162 | + [ -z "${lineno:-}" ] && continue |
| 163 | + if in_bridge_range "$file" "$lineno"; then |
| 164 | + continue |
| 165 | + fi |
| 166 | + if has_negation_context "$content"; then |
| 167 | + continue |
| 168 | + fi |
| 169 | + echo "FORBIDDEN: $file:$lineno: '$phrase'" >&2 |
| 170 | + echo " context: $(printf '%s' "$content" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | cut -c1-120)" >&2 |
| 171 | + echo " → rephrase, or add explicit negation, or document via docs/ECHO-TYPES-OCHRANCE-BRIDGE.adoc" >&2 |
| 172 | + failures=$((failures + 1)) |
| 173 | + done < <(grep -ni -- "$phrase" "$file" 2>/dev/null || true) |
| 174 | + done |
| 175 | + done |
| 176 | +done |
| 177 | + |
| 178 | +if [ "$failures" -gt 0 ]; then |
| 179 | + echo "" >&2 |
| 180 | + echo "Found $failures forbidden-phrase occurrence(s)." >&2 |
| 181 | + echo "See docs/ECHO-TYPES-OCHRANCE-BRIDGE.adoc § 'Forbidden moves'." >&2 |
| 182 | + exit 1 |
| 183 | +fi |
| 184 | + |
| 185 | +echo "Bridge forbidden-phrases check: OK" |
0 commit comments