Skip to content

Commit 4285741

Browse files
feat(governance): port #185 TS-allowlist regex+allowlist to Deno script (Layer 4b/4c) (#225)
## Summary Owner picked #185's two-layer approach 2026-05-27. Both #183 and #185 were already closed since they targeted the eradicated Python heredoc. This is the canonical port into the live Deno detector (`scripts/check-ts-allowlist.ts`). ## Two changes **1. Heading-regex relaxation in `loadExemptionsFromClaudeMd`** Was: `/TypeScript [Ee]xemptions/` (literal). Now: `^#{1,4}\s+.*(?:TypeScript|JavaScript|TS|JS|\.tsx?)\b[^#\n]*[Ee]xemption` Anchored to markdown heading prefix; matches every variant the estate uses (`### TypeScript Exemptions`, `### TypeScript / JavaScript Exemptions (Approved)`, `### TypeScript Exemption`, `### .ts Exemptions ...`). Multi-section: every heading scanned, supports multiple exemption tables in one CLAUDE.md. **2. New Layer 4c: `.governance-allowlist`** Optional plain-text allowlist at the repo root. One glob per line, `#`-comments supported. Decoupled from CLAUDE.md heading text → gate-pass survives doc reformatting. Both sources merge additively. ## Files - `scripts/check-ts-allowlist.ts` — `loadExemptionsFromClaudeMd` (relaxed regex + multi-table) + new `loadExemptionsFromAllowlistFile` + merged `loadExemptions`. Error message lists both options. - `docs/EXEMPTION-MECHANISMS.adoc` — new "Layer 4: Governance language-policy TS-allowlist" section with 4a/4b/4c breakdown, pick-between guide, and historical context. ## Closes Closes #183 (heading regex fix — chosen approach). Closes #185 (Layer 2.5 allowlist file — chosen approach). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 372df41 commit 4285741

2 files changed

Lines changed: 167 additions & 7 deletions

File tree

docs/EXEMPTION-MECHANISMS.adoc

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,113 @@ These have been attempted and should be refused at review:
128128
If a rule is wrong for one repo, the rule needs scoping in the
129129
upstream, not a fork.
130130

131+
== Layer 4: Governance language-policy TS-allowlist
132+
133+
A separate sub-system from Layers 1-3 (which gate the Hypatia
134+
scanner). This layer gates the `governance / Language / package
135+
anti-pattern policy` workflow step that enforces "no new TypeScript"
136+
across the estate. Three sub-layers:
137+
138+
=== 4a: Built-in path / filename allowlist
139+
140+
Hard-coded in `scripts/check-ts-allowlist.ts`. Covers paths that are
141+
*always* exempt regardless of per-repo configuration:
142+
143+
* Directory segments: `bindings`, `tests`, `test`, `scripts`,
144+
`mcp-adapter`, `cli`, `vendor`, `examples`, `ffi`, `node_modules`,
145+
`benchmarks`.
146+
* Any segment containing `vscode` or starting with `deno-`.
147+
* Filename patterns: `*.d.ts`, `mod.ts`, `lsp-server.ts`, `lsp.ts`,
148+
`*-lsp.ts`, `*.bench.ts`, `*_bench.ts`.
149+
150+
Changing this layer requires a standards-repo PR.
151+
152+
=== 4b: Per-repo `.claude/CLAUDE.md` heading-table
153+
154+
Per-repo exemptions documented as a markdown table under a heading
155+
that contains both a language token and `Exemption[s]`. The
156+
heading-match regex is:
157+
158+
----
159+
^#{1,4}\s+.*(?:TypeScript|JavaScript|TS|JS|\.tsx?)\b[^#\n]*[Ee]xemption
160+
----
161+
162+
Matches all of:
163+
164+
* `### TypeScript Exemptions (Approved)`
165+
* `### TypeScript / JavaScript Exemptions (Approved)`
166+
* `### TypeScript Exemption`
167+
* `### .ts Exemptions (foo bar)`
168+
169+
Table rows have shape `| \`<glob>\` | <count> | <rationale> | <unblock-condition> |`.
170+
171+
Multi-section: every heading on the page is scanned, so a repo can
172+
keep separate exemption tables (e.g. one for `.ts` bridge code, one
173+
for vendored test fixtures).
174+
175+
Best for: exemptions whose *rationale* belongs in human-readable
176+
documentation alongside the prose explaining why the repo exists.
177+
178+
=== 4c: `.governance-allowlist` (typed infrastructure file)
179+
180+
Optional plain-text file at the repo root. One glob per line. Lines
181+
starting with `#` are comments. Blank lines are ignored.
182+
183+
----
184+
# Telegram bot binding (no AffineScript binding planned)
185+
avow-protocol/telegram-bot/avow-telegram-bot/**
186+
187+
# Bench harness fixtures
188+
test/perf/fixtures/*.ts
189+
----
190+
191+
Best for: exemptions that are *infrastructure config* (typed,
192+
machine-validated) rather than human-readable rationale prose. Pairs
193+
cleanly with the estate's "machine-readable everywhere" trajectory.
194+
Decoupled from CLAUDE.md heading text so reformatting the docs
195+
doesn't accidentally break the gate.
196+
197+
Both 4b and 4c are additive — declaring an exemption in either is
198+
sufficient. Most repos will pick one or the other.
199+
200+
=== Picking between 4b and 4c
201+
202+
[cols="1,3,3"]
203+
|===
204+
| Layer | Use when | Avoid when
205+
206+
| 4b (CLAUDE.md table)
207+
| The exemption needs human-readable rationale next to the glob.
208+
Reviewers reading CLAUDE.md should immediately understand *why*.
209+
| The repo's CLAUDE.md is large and heading-text drift breaks the
210+
gate.
211+
212+
| 4c (`.governance-allowlist`)
213+
| The exemption is structural and the rationale belongs elsewhere
214+
(tracked in an issue / ADR / commit message). Or the repo wants
215+
the gate-pass list to live in version-controlled infrastructure
216+
rather than prose.
217+
| The exemption is one-off and CLAUDE.md prose is the right home.
218+
|===
219+
220+
=== History
221+
222+
* Original implementation: inline Python heredoc in
223+
`governance-reusable.yml`, regex literal `TypeScript [Ee]xemptions`.
224+
Failed silently on every `affinescript`-style heading (the slash and
225+
`JavaScript` between keywords meant the regex never matched, and 3
226+
legitimate exemptions were dropped → gate red on every PR).
227+
* Python eradication (standards#189): ported to
228+
`scripts/check-ts-allowlist.ts`. Brittle regex carried over.
229+
* Layer 4b fix + Layer 4c introduction (2026-05-27): regex relaxation
230+
+ new typed-infrastructure file. Owner picked the Layer-2.5 approach
231+
(standards#185) over the minimal regex-only fix (standards#183).
232+
This document seeds the doctrine.
233+
131234
== Cross-references
132235

133236
* `docs/HYPATIA-BASELINE-FORMAT.adoc` — the baseline file format.
134237
* `.machine_readable/hypatia-baseline.schema.json` — machine schema.
238+
* `scripts/check-ts-allowlist.ts` — the Deno detector behind Layer 4.
135239
* `hyperpolymath/standards#????` — proposal that landed this consumer.
136240
* `hyperpolymath/hypatia` — the scanner that emits findings.

scripts/check-ts-allowlist.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,44 @@ function globToRegex(g: string): RegExp {
7272

7373
interface Exemption { raw: string; rx: RegExp; }
7474

75-
async function loadExemptions(): Promise<Exemption[]> {
75+
async function loadExemptionsFromClaudeMd(): Promise<Exemption[]> {
76+
// Layer 2 — heading-table exemptions parsed from `.claude/CLAUDE.md`.
77+
//
78+
// Heading regex relaxation (was: literal `TypeScript [Ee]xemptions`):
79+
// now matches any markdown heading containing the substring sequence
80+
// (TypeScript|JavaScript|TS|JS|.tsx?) … Exemption(s). Picks up
81+
// `### TypeScript / JavaScript Exemptions (Approved)` (the
82+
// affinescript form), the singular `### TypeScript Exemption`, and
83+
// `.ts` / `.tsx`-mentioning variants. Anchored to a markdown heading
84+
// prefix so prose mentions of the phrase elsewhere in the file do
85+
// NOT trigger table parsing.
86+
//
87+
// Multi-table support: scans every heading; on hitting any heading
88+
// that's NOT an exemption-section heading we leave table-mode (the
89+
// original "break on first heading" was correct for the heredoc but
90+
// a multi-section file would miss the second exemption table).
7691
const exemptions: Exemption[] = [];
7792
let text: string;
7893
try {
7994
text = await Deno.readTextFile(".claude/CLAUDE.md");
8095
} catch {
8196
return exemptions;
8297
}
98+
const tsHeading =
99+
/^#{1,4}\s+.*(?:TypeScript|JavaScript|TS|JS|\.tsx?)\b[^#\n]*[Ee]xemption/;
100+
const anyHeading = /^#{1,4}\s/;
83101
let inTable = false;
84-
const headingRx = /TypeScript [Ee]xemptions/;
85102
for (const line of text.split("\n")) {
86-
if (headingRx.test(line)) { inTable = true; continue; }
87-
if (inTable && (line.startsWith("### ") || line.startsWith("## ") || line.startsWith("# "))) break;
103+
if (tsHeading.test(line)) {
104+
inTable = true;
105+
continue;
106+
}
107+
if (inTable && anyHeading.test(line)) {
108+
// A different heading — leave table mode but keep scanning for
109+
// another exemption section in the same file.
110+
inTable = false;
111+
continue;
112+
}
88113
if (inTable && line.startsWith("|")) {
89114
const m = line.match(/^\|\s*`([^`]+)`/);
90115
if (m) {
@@ -95,6 +120,34 @@ async function loadExemptions(): Promise<Exemption[]> {
95120
return exemptions;
96121
}
97122

123+
async function loadExemptionsFromAllowlistFile(): Promise<Exemption[]> {
124+
// Layer 2.5 — optional plain-text allowlist at the repo root.
125+
// One glob per line. Lines starting with `#` are comments; blank
126+
// lines are ignored. Decouples gate-pass from documentation prose
127+
// (the CLAUDE.md heading-table is the documented variant; this
128+
// file is the typed-infrastructure variant). Both sources merge
129+
// additively — either alone is sufficient.
130+
const exemptions: Exemption[] = [];
131+
let text: string;
132+
try {
133+
text = await Deno.readTextFile(".governance-allowlist");
134+
} catch {
135+
return exemptions;
136+
}
137+
for (const rawLine of text.split("\n")) {
138+
const line = rawLine.trim();
139+
if (line === "" || line.startsWith("#")) continue;
140+
exemptions.push({ raw: line, rx: globToRegex(line) });
141+
}
142+
return exemptions;
143+
}
144+
145+
async function loadExemptions(): Promise<Exemption[]> {
146+
const fromCm = await loadExemptionsFromClaudeMd();
147+
const fromAllow = await loadExemptionsFromAllowlistFile();
148+
return [...fromCm, ...fromAllow];
149+
}
150+
98151
function exempt(p: string, exemptions: Exemption[]): boolean {
99152
for (const e of exemptions) {
100153
if (e.rx.test(p)) return true;
@@ -138,13 +191,16 @@ async function main() {
138191
console.log("To resolve, choose one:");
139192
console.log(" (a) migrate the file to AffineScript");
140193
console.log(" (b) move to an allowlisted bridge path");
141-
console.log(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md");
194+
console.log(" (c) add an entry to a 'TypeScript Exemptions' table in .claude/CLAUDE.md (Layer 2)");
195+
console.log(" (d) add a line to .governance-allowlist at the repo root (Layer 2.5 — typed infrastructure file)");
196+
console.log("");
197+
console.log("See docs/EXEMPTION-MECHANISMS.adoc for the full mechanism reference.");
142198
if (exemptions.length > 0) {
143-
console.log(`\n(Currently ${exemptions.length} exemption(s) parsed from .claude/CLAUDE.md.)`);
199+
console.log(`\n(Currently ${exemptions.length} exemption(s) parsed across both layers.)`);
144200
}
145201
Deno.exit(1);
146202
}
147-
console.log(`✅ No TypeScript files outside allowlist (${exemptions.length} per-repo exemption(s) parsed).`);
203+
console.log(`✅ No TypeScript files outside allowlist (${exemptions.length} per-repo exemption(s) parsed across CLAUDE.md + .governance-allowlist).`);
148204
}
149205

150206
if (import.meta.main) {

0 commit comments

Comments
 (0)