From 681373b30b3615460ac3c802d6ba8db893b48d45 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:53:03 +0100 Subject: [PATCH] fix ts allowlist path normalization --- scripts/check-ts-allowlist.affine | 38 +++++--- scripts/check-ts-allowlist.deno.js | 24 ++++- scripts/check-ts-allowlist.ts | 57 ++++++++--- scripts/tests/check-ts-allowlist-test.sh | 115 +++++++++++++++++------ 4 files changed, 174 insertions(+), 60 deletions(-) diff --git a/scripts/check-ts-allowlist.affine b/scripts/check-ts-allowlist.affine index b0be5255..608faeb0 100644 --- a/scripts/check-ts-allowlist.affine +++ b/scripts/check-ts-allowlist.affine @@ -88,6 +88,14 @@ fn globToRegex(g: String) -> String { type Exemption = { raw: String, rx: String } +fn normalizeRepoPath(p: String) -> String { + let mut out = p; + while (len(out) > 0 && (string_sub(out, 0, 1) == "." || string_sub(out, 0, 1) == "/")) { + out = string_sub(out, 1, len(out) - 1); + } + return out; +} + fn loadExemptionsFromClaudeMd() -> [Exemption] { let mut exemptions = []; let text = try { @@ -116,11 +124,11 @@ fn loadExemptionsFromClaudeMd() -> [Exemption] { i = i + 1; continue; } - if (inTable && len(line) > 0 && string_sub(line, 0, 1) == "|") { - if (regexMatch(line, "^\\|\\s*`[^`]+`")) { + if (inTable && len(line) > 0) { + if (regexMatch(line, "^\\s*\\|\\s*`[^`]+`")) { let parts = split(line, "`"); if (len(parts) >= 3) { - let raw = parts[1]; + let raw = normalizeRepoPath(trim(parts[1])); exemptions = exemptions ++ [#{ raw: raw, rx: globToRegex(raw) }]; } } @@ -144,7 +152,7 @@ fn loadExemptionsFromAllowlistFile() -> [Exemption] { let lines_len = len(lines); while (i < lines_len) { let rawLine = lines[i]; - let line = trim(rawLine); + let line = normalizeRepoPath(trim(rawLine)); if (line == "" || string_sub(line, 0, 1) == "#") { i = i + 1; continue; @@ -160,22 +168,28 @@ fn loadExemptions() -> [Exemption] { } fn isExempt(p: String, exemptions: [Exemption]) -> Bool { + let target = normalizeRepoPath(trim(p)); let mut i = 0; let ex_len = len(exemptions); while (i < ex_len) { let e = exemptions[i]; - if (regexMatch(p, e.rx)) { return true; } - let mut bare = e.raw; - while (len(bare) > 0 && (string_sub(bare, 0, 1) == "." || string_sub(bare, 0, 1) == "/")) { - bare = string_sub(bare, 1, len(bare) - 1); - } - if (p == bare) { return true; } - if (ends_with(e.raw, "/") && regexMatch(p, "^" ++ bare)) { return true; } + if (regexMatch(target, e.rx)) { return true; } + let bare = normalizeRepoPath(trim(e.raw)); + if (target == bare) { return true; } + if (ends_with(bare, "/") && regexMatch(target, "^" ++ bare)) { return true; } i = i + 1; } return false; } +fn isTypeScriptArtifact(name: String) -> Bool { + if (ends_with(name, ".ts")) { return true; } + if (ends_with(name, ".tsx")) { return true; } + if (ends_with(name, ".ts.bak")) { return true; } + if (ends_with(name, ".tsx.bak")) { return true; } + return false; +} + pub fn main() -> Int { let exemptions = loadExemptions(); let mut found = []; @@ -189,7 +203,7 @@ pub fn main() -> Int { let af_len = len(all_files); while (i < af_len) { let f = all_files[i]; - if (ends_with(f, ".ts") || ends_with(f, ".tsx")) { + if (isTypeScriptArtifact(f)) { let mut skip = false; let segs = split(f, "/"); let mut j = 0; diff --git a/scripts/check-ts-allowlist.deno.js b/scripts/check-ts-allowlist.deno.js index 91748338..b1aa6fa2 100644 --- a/scripts/check-ts-allowlist.deno.js +++ b/scripts/check-ts-allowlist.deno.js @@ -1,5 +1,6 @@ // Generated by AffineScript compiler (Deno-ESM target, issue #122) // SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell // ---- AffineScript Deno-ESM runtime ---- const Some = (value) => ({ tag: "Some", value }); const None = { tag: "None" }; @@ -289,6 +290,12 @@ function globToRegex(g) { } // type Exemption +function normalizeRepoPath(p) { + let out = String(p).trim(); + while (((((out).length) > 0) && ((__as_strSub(out, 0, 1) === ".") || (__as_strSub(out, 0, 1) === "/")))) { out = __as_strSub(out, 1, (((out).length) - 1)); } + return out; +} + function loadExemptionsFromClaudeMd() { let exemptions = []; const text = (() => { try { return (() => { return Deno.readTextFileSync(".claude/CLAUDE.md"); })(); } catch (__e) { return ""; } })(); @@ -299,7 +306,7 @@ function loadExemptionsFromClaudeMd() { const lines = split(text, "\n"); let i = 0; const lines_len = ((lines).length); - while ((i < lines_len)) { const line = lines[i]; if (__as_regexMatch(line, tsHeading)) { inTable = true; i = (i + 1); continue; } if ((inTable && __as_regexMatch(line, anyHeading))) { inTable = false; i = (i + 1); continue; } if (((inTable && (((line).length) > 0)) && (__as_strSub(line, 0, 1) === "|"))) { if (__as_regexMatch(line, "^\\|\\s*`[^`]+`")) { const parts = split(line, "`"); if ((((parts).length) >= 3)) { const raw = parts[1]; exemptions = __as_concat(exemptions, [({ raw: raw, rx: globToRegex(raw) })]); } } } i = (i + 1); } + while ((i < lines_len)) { const line = lines[i]; if (__as_regexMatch(line, tsHeading)) { inTable = true; i = (i + 1); continue; } if ((inTable && __as_regexMatch(line, anyHeading))) { inTable = false; i = (i + 1); continue; } if ((inTable && (((line).length) > 0))) { if (__as_regexMatch(line, "^\\s*\\|\\s*`[^`]+`")) { const parts = split(line, "`"); if ((((parts).length) >= 3)) { const raw = normalizeRepoPath(parts[1]); exemptions = __as_concat(exemptions, [({ raw: raw, rx: globToRegex(raw) })]); } } } i = (i + 1); } return exemptions; } @@ -310,7 +317,7 @@ function loadExemptionsFromAllowlistFile() { const lines = split(text, "\n"); let i = 0; const lines_len = ((lines).length); - while ((i < lines_len)) { const rawLine = lines[i]; const line = String(rawLine).trim(); if (((line === "") || (__as_strSub(line, 0, 1) === "#"))) { i = (i + 1); continue; } exemptions = __as_concat(exemptions, [({ raw: line, rx: globToRegex(line) })]); i = (i + 1); } + while ((i < lines_len)) { const rawLine = lines[i]; const line = normalizeRepoPath(rawLine); if (((line === "") || (__as_strSub(line, 0, 1) === "#"))) { i = (i + 1); continue; } exemptions = __as_concat(exemptions, [({ raw: line, rx: globToRegex(line) })]); i = (i + 1); } return exemptions; } @@ -319,9 +326,18 @@ function loadExemptions() { } function isExempt(p, exemptions) { + const target = normalizeRepoPath(p); let i = 0; const ex_len = ((exemptions).length); - while ((i < ex_len)) { const e = exemptions[i]; if (__as_regexMatch(p, e.rx)) { return true; } let bare = e.raw; while (((((bare).length) > 0) && ((__as_strSub(bare, 0, 1) === ".") || (__as_strSub(bare, 0, 1) === "/")))) { bare = __as_strSub(bare, 1, (((bare).length) - 1)); } if ((p === bare)) { return true; } if ((ends_with(e.raw, "/") && __as_regexMatch(p, __as_concat("^", bare)))) { return true; } i = (i + 1); } + while ((i < ex_len)) { const e = exemptions[i]; if (__as_regexMatch(target, e.rx)) { return true; } const bare = normalizeRepoPath(e.raw); if ((target === bare)) { return true; } if ((ends_with(bare, "/") && __as_regexMatch(target, __as_concat("^", bare)))) { return true; } i = (i + 1); } + return false; +} + +function isTypeScriptArtifact(name) { + if (ends_with(name, ".ts")) { return true; } + if (ends_with(name, ".tsx")) { return true; } + if (ends_with(name, ".ts.bak")) { return true; } + if (ends_with(name, ".tsx.bak")) { return true; } return false; } @@ -331,7 +347,7 @@ export function main() { const all_files = (() => { try { return (() => { return __as_walkRecursive("."); })(); } catch (__e) { return []; } })(); let i = 0; const af_len = ((all_files).length); - while ((i < af_len)) { const f = all_files[i]; if ((ends_with(f, ".ts") || ends_with(f, ".tsx"))) { let skip = false; const segs = split(f, "/"); let j = 0; const segs_len = ((segs).length); while ((j < segs_len)) { const seg = segs[j]; if (((((((seg).length) > 0) && (__as_strSub(seg, 0, 1) === ".")) && (seg !== ".")) && (seg !== ".."))) { skip = true; } j = (j + 1); } if ((!skip)) { found = __as_concat(found, [f]); } } i = (i + 1); } + while ((i < af_len)) { const f = all_files[i]; if (isTypeScriptArtifact(f)) { let skip = false; const segs = split(f, "/"); let j = 0; const segs_len = ((segs).length); while ((j < segs_len)) { const seg = segs[j]; if (((((((seg).length) > 0) && (__as_strSub(seg, 0, 1) === ".")) && (seg !== ".")) && (seg !== ".."))) { skip = true; } j = (j + 1); } if ((!skip)) { found = __as_concat(found, [f]); } } i = (i + 1); } let bad = []; let k = 0; const found_len = ((found).length); diff --git a/scripts/check-ts-allowlist.ts b/scripts/check-ts-allowlist.ts index 1e4ee617..3a6ba9e8 100644 --- a/scripts/check-ts-allowlist.ts +++ b/scripts/check-ts-allowlist.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +// 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 @@ -12,7 +12,8 @@ // 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. +// * Walk every `*.ts` / `*.tsx` file under cwd, skipping dotted dirs +// and treating `.ts.bak` / `.tsx.bak` backups as banned TS artifacts. // * 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-'). @@ -72,6 +73,27 @@ function globToRegex(g: string): RegExp { interface Exemption { raw: string; rx: RegExp; } +function normalizeRepoPath(p: string): string { + let out = p.trim(); + while (out.length > 0 && (out[0] === "." || out[0] === "/")) { + out = out.slice(1); + } + return out; +} + +function normalizeExemptionCell(cell: string): string { + let out = cell.trim(); + const codeSpan = out.match(/^`([^`]+)`$/) ?? out.match(/^`([^`]+)`/); + if (codeSpan) { + out = codeSpan[1].trim(); + } + return normalizeRepoPath(out); +} + +function nonExemptionCell(cell: string): boolean { + return cell === "" || /^:?-{3,}:?$/.test(cell) || /^path\b/i.test(cell); +} + async function loadExemptionsFromClaudeMd(): Promise { // Layer 2 — heading-table exemptions parsed from `.claude/CLAUDE.md`. // @@ -110,10 +132,14 @@ async function loadExemptionsFromClaudeMd(): Promise { inTable = false; continue; } - if (inTable && line.startsWith("|")) { - const m = line.match(/^\|\s*`([^`]+)`/); - if (m) { - exemptions.push({ raw: m[1], rx: globToRegex(m[1]) }); + const tableLine = line.trim(); + if (inTable && tableLine.startsWith("|")) { + const cells = tableLine.split("|"); + if (cells.length >= 3) { + const raw = normalizeExemptionCell(cells[1]); + if (!nonExemptionCell(raw)) { + exemptions.push({ raw, rx: globToRegex(raw) }); + } } } } @@ -135,7 +161,7 @@ async function loadExemptionsFromAllowlistFile(): Promise { return exemptions; } for (const rawLine of text.split("\n")) { - const line = rawLine.trim(); + const line = normalizeExemptionCell(rawLine); if (line === "" || line.startsWith("#")) continue; exemptions.push({ raw: line, rx: globToRegex(line) }); } @@ -149,16 +175,21 @@ async function loadExemptions(): Promise { } function exempt(p: string, exemptions: Exemption[]): boolean { + const target = normalizeRepoPath(p); 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; + if (e.rx.test(target)) return true; + const bare = normalizeRepoPath(e.raw); + if (target === bare) return true; + if (bare.endsWith("/") && target.startsWith(bare)) return true; } return false; } +function isTypeScriptArtifact(name: string): boolean { + return name.endsWith(".ts") || name.endsWith(".tsx") || + name.endsWith(".ts.bak") || name.endsWith(".tsx.bak"); +} + async function* walkTs(dir: string): AsyncIterable { for await (const entry of Deno.readDir(dir)) { const name = entry.name; @@ -168,7 +199,7 @@ async function* walkTs(dir: string): AsyncIterable { if (entry.isDirectory) { yield* walkTs(full); } else if (entry.isFile) { - if (name.endsWith(".ts") || name.endsWith(".tsx")) { + if (isTypeScriptArtifact(name)) { yield full; } } diff --git a/scripts/tests/check-ts-allowlist-test.sh b/scripts/tests/check-ts-allowlist-test.sh index b5662ee9..d344a0d6 100755 --- a/scripts/tests/check-ts-allowlist-test.sh +++ b/scripts/tests/check-ts-allowlist-test.sh @@ -2,21 +2,26 @@ # SPDX-License-Identifier: MPL-2.0 # 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. +# Regression test for scripts/check-ts-allowlist.{ts,deno.js}. Each case +# constructs a fresh fixture tree under a tmpdir, runs each script target 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 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 +SCRIPT_TARGETS=( + "$SCRIPT_DIR/../check-ts-allowlist.ts" + "$SCRIPT_DIR/../check-ts-allowlist.deno.js" +) + +for target in "${SCRIPT_TARGETS[@]}"; do + if [[ ! -f "$target" ]]; then + echo "FATAL: cannot locate $target" >&2 + exit 2 + fi +done PASS=0 FAIL=0 @@ -33,26 +38,30 @@ run_case() { 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 + + local target + for target in "${SCRIPT_TARGETS[@]}"; do + set +e + local out + out="$(cd "$tmp" && deno run --allow-read --no-lock "$target" 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 $(basename "$target") :: $name" + PASS=$((PASS+1)) + else + echo "FAIL $(basename "$target") :: $name (exit=$actual_exit, expected=$expected_exit)" + echo "---- output ----" + echo "$out" + echo "----------------" + FAIL=$((FAIL+1)) + fi + done rm -rf "$tmp" } @@ -111,6 +120,45 @@ setup_table_after_heading_ends() { | `src/A.ts` | This row should NOT count — outside exemption table | EOF } +write_estate_claude_table() { + mkdir -p .claude + cat > .claude/CLAUDE.md <<'EOF' +# CLAUDE.md + +### TypeScript Exemptions (Approved) + +| Path / Pattern | Rationale | Unblock condition | +|---|---|---| +| `affinescript-ecosystem/affinescript-deno-test/**` | Bootstrap shim. | Later. | +| `aggregate-library/src/test-runner.ts` | Pre-migration test runner. | Later. | +| `rescript-ecosystem/packages/core/runtime-tools/bin/rrt.ts` | Legacy runtime tool. | Later. | +EOF +} +setup_estate_glob_exemption() { + write_estate_claude_table + mkdir -p affinescript-ecosystem/affinescript-deno-test + touch affinescript-ecosystem/affinescript-deno-test/anything.ts +} +setup_estate_aggregate_exact_exemption() { + write_estate_claude_table + mkdir -p aggregate-library/src + touch aggregate-library/src/test-runner.ts +} +setup_estate_rrt_exact_exemption() { + write_estate_claude_table + mkdir -p rescript-ecosystem/packages/core/runtime-tools/bin + touch rescript-ecosystem/packages/core/runtime-tools/bin/rrt.ts +} +setup_estate_other_ts_blocked() { + write_estate_claude_table + mkdir -p some/other/path + touch some/other/path/new-thing.ts +} +setup_estate_rrt_near_miss_blocked() { + write_estate_claude_table + mkdir -p rescript-ecosystem/packages/core/runtime-tools/bin + touch rescript-ecosystem/packages/core/runtime-tools/bin/rrt.ts.bak +} 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 @@ -125,6 +173,11 @@ run_case "*.d.ts is builtin-allowed" 0 "No TypeScript files outsid 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 +run_case "estate glob exemption allows affinescript-deno-test" 0 "3 per-repo exemption" setup_estate_glob_exemption +run_case "estate exact exemption allows aggregate test runner" 0 "3 per-repo exemption" setup_estate_aggregate_exact_exemption +run_case "estate exact exemption allows rescript rrt.ts" 0 "3 per-repo exemption" setup_estate_rrt_exact_exemption +run_case "estate table still blocks unrelated new TS" 1 "some/other/path/new-thing.ts" setup_estate_other_ts_blocked +run_case "estate exact exemption blocks rrt.ts.bak near miss" 1 "rrt.ts.bak" setup_estate_rrt_near_miss_blocked echo echo "=== SUMMARY ==="