Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 29 additions & 86 deletions .github/workflows/governance-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,94 +131,37 @@ jobs:
repository: ${{ github.repository }}
ref: ${{ github.ref }}

- name: Check for TypeScript
run: |
python3 << 'PYEOF'
import re, sys, pathlib

DIR_NAMES_ALLOWED = {
'bindings', 'tests', 'test', 'scripts',
'mcp-adapter', 'cli', 'vendor', 'examples', 'ffi',
'node_modules', 'benchmarks',
}

def builtin_allowed(p):
if p.endswith('.d.ts'):
return True
base = p.rsplit('/', 1)[-1]
if base == 'mod.ts':
return True
if base in ('lsp-server.ts', 'lsp_server.ts', 'lsp.ts') or base.endswith('-lsp.ts'):
return True
if base.endswith('.bench.ts') or base.endswith('_bench.ts'):
return True
segs = p.split('/')
for s in segs[:-1]:
if s in DIR_NAMES_ALLOWED:
return True
if 'vscode' in s:
return True
if s.startswith('deno-'):
return True
return False

def glob_to_regex(g):
out = []
for c in g.lstrip('./'):
if c == '*': out.append('.*')
elif c == '?': out.append('.')
elif c in '.+(){}[]|^$\\': out.append(re.escape(c))
else: out.append(c)
return re.compile('^' + ''.join(out) + '$')

exemption_patterns = []
claude_md = pathlib.Path('.claude/CLAUDE.md')
if claude_md.exists():
in_table = False
for line in claude_md.read_text(encoding='utf-8').splitlines():
if re.search(r'TypeScript [Ee]xemptions', line):
in_table = True
continue
if in_table and line.startswith(('### ', '## ', '# ')):
break
if in_table and line.startswith('|'):
m = re.match(r'\|\s*`([^`]+)`', line)
if m:
exemption_patterns.append((m.group(1), glob_to_regex(m.group(1))))

def exempt(p):
for raw, regex in exemption_patterns:
if regex.match(p):
return True
if p == raw.lstrip('./'):
return True
if raw.endswith('/') and p.startswith(raw.lstrip('./')):
return True
return False
# Estate language policy bans Python with no exceptions (CLAUDE.md
# Language Policy; SaltStack exception removed 2026-01-03). The
# previous in-line `python3 << PYEOF` heredoc made this very gate a
# self-referential violation — same structural class as the CSA001
# self-loop fixed in hypatia#328. Eradicated by porting the logic
# to a Deno script that lives in this standards repo.
#
# Implementation note: a reusable workflow only auto-checks-out its
# YAML, not sibling files in its repo. So we explicitly check out
# this repo at the same ref the caller picked (via
# `github.workflow_sha`, the SHA actually loaded by the runner)
# into `.standards-checkout/`, then run the script from there.
- name: Set up Deno
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
with:
deno-version: v2.x

found = []
for ext in ('ts', 'tsx'):
for p in pathlib.Path('.').rglob(f'*.{ext}'):
parts = p.parts
if any(part.startswith('.') and part not in ('.', '..') for part in parts):
continue
found.append(p.as_posix().lstrip('./'))
- name: Check out standards repo for shared scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: hyperpolymath/standards
ref: ${{ github.workflow_sha }}
path: .standards-checkout
# Sparse-checkout only the scripts dir to keep this fast.
sparse-checkout: |
scripts
sparse-checkout-cone-mode: false

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

# Shared escape hatch for the banned-language-file checks below.
# Honours three exemption mechanisms (see
Expand Down
152 changes: 152 additions & 0 deletions scripts/check-ts-allowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: PMPL-1.0-or-later

Check failure

Code scanning / Hypatia

Hypatia cicd_rules: banned_language_file Error

TypeScript file detected -- banned language
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
//
// check-ts-allowlist.ts — Deno port of the inline python3 heredoc that used
// to live in `.github/workflows/governance-reusable.yml` step
// "Check for TypeScript".
//
// Why this file exists: estate language policy bans Python in all repos
// (SaltStack exception removed 2026-01-03). The governance-reusable
// workflow that enforces the policy was itself written in inline Python —
// a self-referential violation, structurally identical to the CSA001
// self-loop fixed in hypatia#328. This script eliminates the violation.
//
// Behaviour MUST stay byte-identical to the previous Python implementation:
// * Walk every `*.ts` / `*.tsx` file under cwd, skipping dotted dirs.
// * Allow files in the built-in directory/path allowlist
// (bindings/tests/scripts/vendor/examples/ffi/benchmarks/cli, plus any
// segment containing 'vscode' or starting with 'deno-').
// * Allow specific filename patterns: `*.d.ts`, `mod.ts`, `lsp-server.ts`,
// `lsp.ts`, `*-lsp.ts`, `*.bench.ts`, `*_bench.ts`.
// * Load per-repo exemption table from `.claude/CLAUDE.md` heading
// `TypeScript Exemptions` (regex: `TypeScript [Ee]xemptions`). Table
// rows have `| \`glob\` | …` shape.
// * Exit 1 with the formatted error block if any non-exempt files remain;
// otherwise print the success line.
//
// Permission scope is `--allow-read` only. No network, no env, no write.

const DIR_NAMES_ALLOWED = new Set([
"bindings", "tests", "test", "scripts",
"mcp-adapter", "cli", "vendor", "examples", "ffi",
"node_modules", "benchmarks",
]);

function builtinAllowed(p: string): boolean {
if (p.endsWith(".d.ts")) return true;
const base = p.split("/").pop()!;
if (base === "mod.ts") return true;
if (
base === "lsp-server.ts" || base === "lsp_server.ts" || base === "lsp.ts" ||
base.endsWith("-lsp.ts")
) return true;
if (base.endsWith(".bench.ts") || base.endsWith("_bench.ts")) return true;
const segs = p.split("/");
for (let i = 0; i < segs.length - 1; i++) {
const s = segs[i];
if (DIR_NAMES_ALLOWED.has(s)) return true;
if (s.includes("vscode")) return true;
if (s.startsWith("deno-")) return true;
}
return false;
}

function globToRegex(g: string): RegExp {
// The Python implementation stripped a leading "./" via `.lstrip('./')`
// which is a multi-char strip (any leading '.' OR '/' character),
// matching `./foo` -> `foo` and `../foo` -> `foo` alike. The intent
// (matching the original behaviour) is to normalise leading-path-cruft
// off the glob before regex-translating it.
let g2 = g;
while (g2.length > 0 && (g2[0] === "." || g2[0] === "/")) g2 = g2.slice(1);
let out = "";
const regexEsc = ".+(){}[]|^$\\";
for (const c of g2) {
if (c === "*") out += ".*";
else if (c === "?") out += ".";
else if (regexEsc.includes(c)) out += "\\" + c;
else out += c;
}
return new RegExp("^" + out + "$");
}

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

async function loadExemptions(): Promise<Exemption[]> {
const exemptions: Exemption[] = [];
let text: string;
try {
text = await Deno.readTextFile(".claude/CLAUDE.md");
} catch {
return exemptions;
}
let inTable = false;
const headingRx = /TypeScript [Ee]xemptions/;
for (const line of text.split("\n")) {
if (headingRx.test(line)) { inTable = true; continue; }
if (inTable && (line.startsWith("### ") || line.startsWith("## ") || line.startsWith("# "))) break;
if (inTable && line.startsWith("|")) {
const m = line.match(/^\|\s*`([^`]+)`/);
if (m) {
exemptions.push({ raw: m[1], rx: globToRegex(m[1]) });
}
}
}
return exemptions;
}

function exempt(p: string, exemptions: Exemption[]): boolean {
for (const e of exemptions) {
if (e.rx.test(p)) return true;
let bare = e.raw;
while (bare.length > 0 && (bare[0] === "." || bare[0] === "/")) bare = bare.slice(1);
if (p === bare) return true;
if (e.raw.endsWith("/") && p.startsWith(bare)) return true;
}
return false;
}

async function* walkTs(dir: string): AsyncIterable<string> {
for await (const entry of Deno.readDir(dir)) {
const name = entry.name;
// Skip dotfiles/dotted dirs (matching Python's check on path parts).
if (name.startsWith(".") && name !== "." && name !== "..") continue;
const full = dir === "." ? name : `${dir}/${name}`;
if (entry.isDirectory) {
yield* walkTs(full);
} else if (entry.isFile) {
if (name.endsWith(".ts") || name.endsWith(".tsx")) {
yield full;
}
}
}
}

async function main() {
const exemptions = await loadExemptions();
const found: string[] = [];
for await (const f of walkTs(".")) {
found.push(f);
}
const bad = found
.filter((f) => !(builtinAllowed(f) || exempt(f, exemptions)))
.sort();
if (bad.length > 0) {
console.log("❌ TypeScript files detected outside the allowlist.\n");
for (const f of bad) console.log(` ${f}`);
console.log("");
console.log("To resolve, choose one:");
console.log(" (a) migrate the file to AffineScript");
console.log(" (b) move to an allowlisted bridge path");
console.log(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md");
if (exemptions.length > 0) {
console.log(`\n(Currently ${exemptions.length} exemption(s) parsed from .claude/CLAUDE.md.)`);
}
Deno.exit(1);
}
console.log(`✅ No TypeScript files outside allowlist (${exemptions.length} per-repo exemption(s) parsed).`);
}

if (import.meta.main) {
await main();
}
Loading
Loading