diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 880db774..a9356c7b 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -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 diff --git a/scripts/check-ts-allowlist.ts b/scripts/check-ts-allowlist.ts new file mode 100644 index 00000000..c627baa4 --- /dev/null +++ b/scripts/check-ts-allowlist.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// 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 { + 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 { + 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(); +} diff --git a/scripts/tests/check-ts-allowlist-test.sh b/scripts/tests/check-ts-allowlist-test.sh new file mode 100755 index 00000000..c3b2c5a4 --- /dev/null +++ b/scripts/tests/check-ts-allowlist-test.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Regression test for scripts/check-ts-allowlist.ts. Each case constructs a +# fresh fixture tree under a tmpdir, runs the script with `--allow-read`, +# and asserts exit code + key output substrings. Mirrors the behaviour the +# previous inline-python step was relied on for, so a future maintenance +# change to the Deno script cannot silently regress estate-wide policy. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DENO_SCRIPT="$SCRIPT_DIR/../check-ts-allowlist.ts" + +if [[ ! -f "$DENO_SCRIPT" ]]; then + echo "FATAL: cannot locate $DENO_SCRIPT" >&2 + exit 2 +fi + +PASS=0 +FAIL=0 + +run_case() { + local name="$1"; shift + local expected_exit="$1"; shift + local expected_substr="$1"; shift + local setup_fn="$1"; shift + + local tmp + tmp="$(mktemp -d)" + ( + cd "$tmp" + "$setup_fn" + ) + set +e + local out + out="$(cd "$tmp" && deno run --allow-read "$DENO_SCRIPT" 2>&1)" + local actual_exit=$? + set -e + + local ok=true + if [[ "$actual_exit" -ne "$expected_exit" ]]; then ok=false; fi + if [[ -n "$expected_substr" && "$out" != *"$expected_substr"* ]]; then ok=false; fi + + if $ok; then + echo "ok $name" + PASS=$((PASS+1)) + else + echo "FAIL $name (exit=$actual_exit, expected=$expected_exit)" + echo "---- output ----" + echo "$out" + echo "----------------" + FAIL=$((FAIL+1)) + fi + rm -rf "$tmp" +} + +setup_mod_ts() { touch mod.ts; } +setup_bindings_dir() { mkdir -p bindings && touch bindings/foo.ts; } +setup_vendor_dir() { mkdir -p vendor && touch vendor/x.ts; } +setup_bare_violation() { mkdir -p src && touch src/Foo.ts; } +setup_exempted_via_claude() { + mkdir -p src .claude + touch src/Foo.ts + cat > .claude/CLAUDE.md <<'EOF' +# CLAUDE.md + +### TypeScript Exemptions (Approved) + +| Path | Notes | +|---|---| +| `src/Foo.ts` | Documented exemption | +EOF +} +setup_glob_exemption() { + mkdir -p src/sub .claude + touch src/sub/bar.ts + cat > .claude/CLAUDE.md <<'EOF' +# CLAUDE.md + +### TypeScript Exemptions (Approved) + +| Path | Notes | +|---|---| +| `src/sub/*.ts` | Glob exemption | +EOF +} +setup_hidden_dir() { mkdir -p .secret && touch .secret/foo.ts; } +setup_bench_file() { mkdir -p src && touch src/parser.bench.ts; } +setup_lsp_file() { touch lsp.ts && touch frontend-lsp.ts; } +setup_dts_file() { mkdir -p types && touch types/global.d.ts; } +setup_vscode_dir() { mkdir -p packages/vscode-ext && touch packages/vscode-ext/extension.ts; } +setup_deno_prefix_dir() { mkdir -p deno-lib && touch deno-lib/index.ts; } +setup_table_after_heading_ends() { + mkdir -p src .claude + touch src/A.ts + cat > .claude/CLAUDE.md <<'EOF' +# CLAUDE.md + +### TypeScript Exemptions (Approved) + +| Path | Notes | +|---|---| +| `src/B.ts` | Documented exemption | + +### Some Other Heading + +| Path | Notes | +|---|---| +| `src/A.ts` | This row should NOT count — outside exemption table | +EOF +} + +run_case "mod.ts is builtin-allowed" 0 "No TypeScript files outside allowlist" setup_mod_ts +run_case "bindings/ is builtin-allowed" 0 "No TypeScript files outside allowlist" setup_bindings_dir +run_case "vendor/ is builtin-allowed" 0 "No TypeScript files outside allowlist" setup_vendor_dir +run_case "bare src/Foo.ts fails without exemption" 1 "src/Foo.ts" setup_bare_violation +run_case "CLAUDE.md exemption lets src/Foo.ts pass" 0 "1 per-repo exemption" setup_exempted_via_claude +run_case "glob exemption matches src/sub/bar.ts" 0 "1 per-repo exemption" setup_glob_exemption +run_case "dotted .secret dir is skipped" 0 "No TypeScript files outside allowlist" setup_hidden_dir +run_case "*.bench.ts is builtin-allowed" 0 "No TypeScript files outside allowlist" setup_bench_file +run_case "lsp.ts + *-lsp.ts are builtin-allowed" 0 "No TypeScript files outside allowlist" setup_lsp_file +run_case "*.d.ts is builtin-allowed" 0 "No TypeScript files outside allowlist" setup_dts_file +run_case "directory containing 'vscode' allowed" 0 "No TypeScript files outside allowlist" setup_vscode_dir +run_case "directory starting 'deno-' allowed" 0 "No TypeScript files outside allowlist" setup_deno_prefix_dir +run_case "later heading closes the exemption table" 1 "src/A.ts" setup_table_after_heading_ends + +echo +echo "=== SUMMARY ===" +echo "Pass: $PASS" +echo "Fail: $FAIL" +[[ $FAIL -eq 0 ]]