Skip to content

Commit 0ec0b83

Browse files
fix(governance): eradicate inline Python from governance-reusable.yml (#189)
## Summary Estate language policy bans Python with no exceptions (CLAUDE.md Language Policy; the SaltStack exception was removed 2026-01-03). The `governance-reusable.yml` workflow that *enforces* this policy was itself written as an 85-line inline `python3 << PYEOF` heredoc — a self-referential violation, structurally identical to the CSA001 self-loop fixed in hypatia#328 (the security gate alerting on itself). This PR replaces the inline Python with a Deno script. ## Changes - **`scripts/check-ts-allowlist.ts`** (new) — Deno port. `--allow-read` only; no network, no env, no write. - **`scripts/tests/check-ts-allowlist-test.sh`** (new) — 13 regression cases (builtin allows + CLAUDE.md table parsing). - **`.github/workflows/governance-reusable.yml`** — replaces the python3 heredoc step with `setup-deno` + `actions/checkout` of `hyperpolymath/standards@${{ github.workflow_sha }}` into `.standards-checkout/` (sparse `scripts/`) + `deno run --allow-read .standards-checkout/scripts/check-ts-allowlist.ts`. ## Behaviour preservation Byte-identical refactor, not a policy change. All 13 fixture cases pass locally. Dogfooded against this standards repo itself: `✅ No TypeScript files outside allowlist (1 per-repo exemption(s) parsed)` — matches the Telegraf bot carve-out in `.claude/CLAUDE.md` § TypeScript Exemptions. ## Why now The standards repo currently violates its own no-Python rule. Merging unblocks the per-repo Python sweep across the rest of the estate without leaving a CI gate that contradicts the policy it enforces. ## Test plan - [ ] CI: governance-reusable still passes against the standards repo itself - [ ] `bash scripts/tests/check-ts-allowlist-test.sh` — 13/13 pass - [ ] Manual: a caller repo with no `.claude/CLAUDE.md` gets `0 per-repo exemption(s) parsed` Refs: hypatia#328 (CSA001 self-loop precedent), standards#168 (parent reusable). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 66424f3 commit 0ec0b83

3 files changed

Lines changed: 314 additions & 86 deletions

File tree

.github/workflows/governance-reusable.yml

Lines changed: 29 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -131,94 +131,37 @@ jobs:
131131
repository: ${{ github.repository }}
132132
ref: ${{ github.ref }}
133133

134-
- name: Check for TypeScript
135-
run: |
136-
python3 << 'PYEOF'
137-
import re, sys, pathlib
138-
139-
DIR_NAMES_ALLOWED = {
140-
'bindings', 'tests', 'test', 'scripts',
141-
'mcp-adapter', 'cli', 'vendor', 'examples', 'ffi',
142-
'node_modules', 'benchmarks',
143-
}
144-
145-
def builtin_allowed(p):
146-
if p.endswith('.d.ts'):
147-
return True
148-
base = p.rsplit('/', 1)[-1]
149-
if base == 'mod.ts':
150-
return True
151-
if base in ('lsp-server.ts', 'lsp_server.ts', 'lsp.ts') or base.endswith('-lsp.ts'):
152-
return True
153-
if base.endswith('.bench.ts') or base.endswith('_bench.ts'):
154-
return True
155-
segs = p.split('/')
156-
for s in segs[:-1]:
157-
if s in DIR_NAMES_ALLOWED:
158-
return True
159-
if 'vscode' in s:
160-
return True
161-
if s.startswith('deno-'):
162-
return True
163-
return False
164-
165-
def glob_to_regex(g):
166-
out = []
167-
for c in g.lstrip('./'):
168-
if c == '*': out.append('.*')
169-
elif c == '?': out.append('.')
170-
elif c in '.+(){}[]|^$\\': out.append(re.escape(c))
171-
else: out.append(c)
172-
return re.compile('^' + ''.join(out) + '$')
173-
174-
exemption_patterns = []
175-
claude_md = pathlib.Path('.claude/CLAUDE.md')
176-
if claude_md.exists():
177-
in_table = False
178-
for line in claude_md.read_text(encoding='utf-8').splitlines():
179-
if re.search(r'TypeScript [Ee]xemptions', line):
180-
in_table = True
181-
continue
182-
if in_table and line.startswith(('### ', '## ', '# ')):
183-
break
184-
if in_table and line.startswith('|'):
185-
m = re.match(r'\|\s*`([^`]+)`', line)
186-
if m:
187-
exemption_patterns.append((m.group(1), glob_to_regex(m.group(1))))
188-
189-
def exempt(p):
190-
for raw, regex in exemption_patterns:
191-
if regex.match(p):
192-
return True
193-
if p == raw.lstrip('./'):
194-
return True
195-
if raw.endswith('/') and p.startswith(raw.lstrip('./')):
196-
return True
197-
return False
134+
# Estate language policy bans Python with no exceptions (CLAUDE.md
135+
# Language Policy; SaltStack exception removed 2026-01-03). The
136+
# previous in-line `python3 << PYEOF` heredoc made this very gate a
137+
# self-referential violation — same structural class as the CSA001
138+
# self-loop fixed in hypatia#328. Eradicated by porting the logic
139+
# to a Deno script that lives in this standards repo.
140+
#
141+
# Implementation note: a reusable workflow only auto-checks-out its
142+
# YAML, not sibling files in its repo. So we explicitly check out
143+
# this repo at the same ref the caller picked (via
144+
# `github.workflow_sha`, the SHA actually loaded by the runner)
145+
# into `.standards-checkout/`, then run the script from there.
146+
- name: Set up Deno
147+
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
148+
with:
149+
deno-version: v2.x
198150

199-
found = []
200-
for ext in ('ts', 'tsx'):
201-
for p in pathlib.Path('.').rglob(f'*.{ext}'):
202-
parts = p.parts
203-
if any(part.startswith('.') and part not in ('.', '..') for part in parts):
204-
continue
205-
found.append(p.as_posix().lstrip('./'))
151+
- name: Check out standards repo for shared scripts
152+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
153+
with:
154+
repository: hyperpolymath/standards
155+
ref: ${{ github.workflow_sha }}
156+
path: .standards-checkout
157+
# Sparse-checkout only the scripts dir to keep this fast.
158+
sparse-checkout: |
159+
scripts
160+
sparse-checkout-cone-mode: false
206161

207-
bad = sorted(f for f in found if not (builtin_allowed(f) or exempt(f)))
208-
if bad:
209-
print("❌ TypeScript files detected outside the allowlist.\n")
210-
for f in bad:
211-
print(f" {f}")
212-
print()
213-
print("To resolve, choose one:")
214-
print(" (a) migrate the file to AffineScript")
215-
print(" (b) move to an allowlisted bridge path")
216-
print(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md")
217-
if exemption_patterns:
218-
print(f"\n(Currently {len(exemption_patterns)} exemption(s) parsed from .claude/CLAUDE.md.)")
219-
sys.exit(1)
220-
print(f"✅ No TypeScript files outside allowlist ({len(exemption_patterns)} per-repo exemption(s) parsed).")
221-
PYEOF
162+
- name: Check for TypeScript
163+
# Read-only execution; never writes outside the runner workspace.
164+
run: deno run --allow-read .standards-checkout/scripts/check-ts-allowlist.ts
222165

223166
# Shared escape hatch for the banned-language-file checks below.
224167
# Honours three exemption mechanisms (see

scripts/check-ts-allowlist.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
3+
//
4+
// check-ts-allowlist.ts — Deno port of the inline python3 heredoc that used
5+
// to live in `.github/workflows/governance-reusable.yml` step
6+
// "Check for TypeScript".
7+
//
8+
// Why this file exists: estate language policy bans Python in all repos
9+
// (SaltStack exception removed 2026-01-03). The governance-reusable
10+
// workflow that enforces the policy was itself written in inline Python —
11+
// a self-referential violation, structurally identical to the CSA001
12+
// self-loop fixed in hypatia#328. This script eliminates the violation.
13+
//
14+
// Behaviour MUST stay byte-identical to the previous Python implementation:
15+
// * Walk every `*.ts` / `*.tsx` file under cwd, skipping dotted dirs.
16+
// * Allow files in the built-in directory/path allowlist
17+
// (bindings/tests/scripts/vendor/examples/ffi/benchmarks/cli, plus any
18+
// segment containing 'vscode' or starting with 'deno-').
19+
// * Allow specific filename patterns: `*.d.ts`, `mod.ts`, `lsp-server.ts`,
20+
// `lsp.ts`, `*-lsp.ts`, `*.bench.ts`, `*_bench.ts`.
21+
// * Load per-repo exemption table from `.claude/CLAUDE.md` heading
22+
// `TypeScript Exemptions` (regex: `TypeScript [Ee]xemptions`). Table
23+
// rows have `| \`glob\` | …` shape.
24+
// * Exit 1 with the formatted error block if any non-exempt files remain;
25+
// otherwise print the success line.
26+
//
27+
// Permission scope is `--allow-read` only. No network, no env, no write.
28+
29+
const DIR_NAMES_ALLOWED = new Set([
30+
"bindings", "tests", "test", "scripts",
31+
"mcp-adapter", "cli", "vendor", "examples", "ffi",
32+
"node_modules", "benchmarks",
33+
]);
34+
35+
function builtinAllowed(p: string): boolean {
36+
if (p.endsWith(".d.ts")) return true;
37+
const base = p.split("/").pop()!;
38+
if (base === "mod.ts") return true;
39+
if (
40+
base === "lsp-server.ts" || base === "lsp_server.ts" || base === "lsp.ts" ||
41+
base.endsWith("-lsp.ts")
42+
) return true;
43+
if (base.endsWith(".bench.ts") || base.endsWith("_bench.ts")) return true;
44+
const segs = p.split("/");
45+
for (let i = 0; i < segs.length - 1; i++) {
46+
const s = segs[i];
47+
if (DIR_NAMES_ALLOWED.has(s)) return true;
48+
if (s.includes("vscode")) return true;
49+
if (s.startsWith("deno-")) return true;
50+
}
51+
return false;
52+
}
53+
54+
function globToRegex(g: string): RegExp {
55+
// The Python implementation stripped a leading "./" via `.lstrip('./')`
56+
// which is a multi-char strip (any leading '.' OR '/' character),
57+
// matching `./foo` -> `foo` and `../foo` -> `foo` alike. The intent
58+
// (matching the original behaviour) is to normalise leading-path-cruft
59+
// off the glob before regex-translating it.
60+
let g2 = g;
61+
while (g2.length > 0 && (g2[0] === "." || g2[0] === "/")) g2 = g2.slice(1);
62+
let out = "";
63+
const regexEsc = ".+(){}[]|^$\\";
64+
for (const c of g2) {
65+
if (c === "*") out += ".*";
66+
else if (c === "?") out += ".";
67+
else if (regexEsc.includes(c)) out += "\\" + c;
68+
else out += c;
69+
}
70+
return new RegExp("^" + out + "$");
71+
}
72+
73+
interface Exemption { raw: string; rx: RegExp; }
74+
75+
async function loadExemptions(): Promise<Exemption[]> {
76+
const exemptions: Exemption[] = [];
77+
let text: string;
78+
try {
79+
text = await Deno.readTextFile(".claude/CLAUDE.md");
80+
} catch {
81+
return exemptions;
82+
}
83+
let inTable = false;
84+
const headingRx = /TypeScript [Ee]xemptions/;
85+
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;
88+
if (inTable && line.startsWith("|")) {
89+
const m = line.match(/^\|\s*`([^`]+)`/);
90+
if (m) {
91+
exemptions.push({ raw: m[1], rx: globToRegex(m[1]) });
92+
}
93+
}
94+
}
95+
return exemptions;
96+
}
97+
98+
function exempt(p: string, exemptions: Exemption[]): boolean {
99+
for (const e of exemptions) {
100+
if (e.rx.test(p)) return true;
101+
let bare = e.raw;
102+
while (bare.length > 0 && (bare[0] === "." || bare[0] === "/")) bare = bare.slice(1);
103+
if (p === bare) return true;
104+
if (e.raw.endsWith("/") && p.startsWith(bare)) return true;
105+
}
106+
return false;
107+
}
108+
109+
async function* walkTs(dir: string): AsyncIterable<string> {
110+
for await (const entry of Deno.readDir(dir)) {
111+
const name = entry.name;
112+
// Skip dotfiles/dotted dirs (matching Python's check on path parts).
113+
if (name.startsWith(".") && name !== "." && name !== "..") continue;
114+
const full = dir === "." ? name : `${dir}/${name}`;
115+
if (entry.isDirectory) {
116+
yield* walkTs(full);
117+
} else if (entry.isFile) {
118+
if (name.endsWith(".ts") || name.endsWith(".tsx")) {
119+
yield full;
120+
}
121+
}
122+
}
123+
}
124+
125+
async function main() {
126+
const exemptions = await loadExemptions();
127+
const found: string[] = [];
128+
for await (const f of walkTs(".")) {
129+
found.push(f);
130+
}
131+
const bad = found
132+
.filter((f) => !(builtinAllowed(f) || exempt(f, exemptions)))
133+
.sort();
134+
if (bad.length > 0) {
135+
console.log("❌ TypeScript files detected outside the allowlist.\n");
136+
for (const f of bad) console.log(` ${f}`);
137+
console.log("");
138+
console.log("To resolve, choose one:");
139+
console.log(" (a) migrate the file to AffineScript");
140+
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");
142+
if (exemptions.length > 0) {
143+
console.log(`\n(Currently ${exemptions.length} exemption(s) parsed from .claude/CLAUDE.md.)`);
144+
}
145+
Deno.exit(1);
146+
}
147+
console.log(`✅ No TypeScript files outside allowlist (${exemptions.length} per-repo exemption(s) parsed).`);
148+
}
149+
150+
if (import.meta.main) {
151+
await main();
152+
}

0 commit comments

Comments
 (0)