From ff89c746be1df15166e80819f9fee77115296546 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:10:59 +0100 Subject: [PATCH 1/7] feat(gates): run check-ts-allowlist as bash + awk, retiring Deno MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Scan for hand-authored JavaScript/TypeScript` is a REQUIRED context on the Optimus-Branch ruleset and runs on every estate repo through governance-reusable.yml. Until now it installed a Deno runtime and executed `check-ts-allowlist.deno.js`, an artifact compiled from an AffineScript source. The estate has retired Deno, so the gate is reimplemented in the shell it already runs in. - `scripts/check-ts-allowlist.sh` — bash + awk, no JS runtime, no network, read-only. shellcheck `-S style` clean. - The existing 18-case corpus was first run against BOTH implementations (36 assertions, identical verdicts on every case) before the Deno target was removed; it now runs 18/18 against the shell gate alone. Proven fallible: breaking the `deno-*` directory rule fails exactly the "directory starting 'deno-' allowed" case and nothing else. - `governance-reusable.yml`: the `Set up Deno` step and the `deno run` invocation are gone; the step now runs `bash …/check-ts-allowlist.sh`. - `self-test.yml`: the Deno test-runtime install is gone. - Deleted `check-ts-allowlist.deno.js` and its `check-ts-allowlist.affine` source together, plus the `check-ts-allowlist-drift` Justfile recipe that was their only build path. Keeping the source alone would leave an unbuildable orphan that still looked live. Both compiled with `--deno-esm`; the workflow's drift step was additionally `continue-on-error: true` emitting only `::warning::`, so it could not fail — it is deleted rather than ported. `just --evaluate` parses. Full shell suite: 35/35 test files pass. --- .github/workflows/governance-reusable.yml | 47 +-- .github/workflows/self-test.yml | 9 - Justfile | 13 - scripts/check-ts-allowlist.affine | 245 --------------- scripts/check-ts-allowlist.deno.js | 360 ---------------------- scripts/check-ts-allowlist.sh | 173 +++++++++++ scripts/tests/check-ts-allowlist-test.sh | 18 +- 7 files changed, 185 insertions(+), 680 deletions(-) delete mode 100644 scripts/check-ts-allowlist.affine delete mode 100644 scripts/check-ts-allowlist.deno.js create mode 100755 scripts/check-ts-allowlist.sh diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 56ed958bd..59eb178bb 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -354,11 +354,6 @@ jobs: # drift is just whatever's on standards/main between the reusable # version and the script version — acceptable since scripts here # are read-only governance checks. - - name: Set up Deno - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 - with: - deno-version: v2.x - - name: Check out standards repo for shared scripts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -373,18 +368,9 @@ jobs: - name: Check for TypeScript # Read-only execution; never writes outside the runner workspace. - # `--no-lock` so an empty / stale / missing `deno.lock` doesn't fail - # `deno run` before the file-walker even starts — the script does not - # import anything, so the lockfile is irrelevant to its execution. - # See standards#294. - # - # Runs the AffineScript-compiled `.deno.js` (source of truth: - # `scripts/check-ts-allowlist.affine`). The .ts archetype is kept - # alongside for the regression suite (`scripts/tests/check-ts- - # allowlist-test.sh`) and for parallel-validation during the - # TS→AffineScript migration (standards#239 / #241). Retirement of - # the .ts is a separate follow-up after the dual-target window. - run: deno run --allow-read --no-lock .standards-checkout/scripts/check-ts-allowlist.deno.js + # Pure bash + awk, so no JS runtime is installed on the runner. + # Source of truth: `scripts/check-ts-allowlist.sh` in standards. + run: bash .standards-checkout/scripts/check-ts-allowlist.sh - name: Check language-policy invariants run: | @@ -399,33 +385,6 @@ jobs: fi bash "$SCRIPT" - - name: check-ts-allowlist source/compile drift (informational) - # Non-blocking — informational until the AffineScript compiler - # output is hash-pinned per compiler version. The compiler header - # currently stamps "Generated by AffineScript compiler" which is - # a moving target as the codegen evolves, so spurious diff = - # "compiler bumped" vs real diff = "someone edited .affine - # without recompiling". Promotion to blocking is gated on a - # compiler-version pin landing (see standards#312). - continue-on-error: true - run: | - if ! command -v affinescript >/dev/null 2>&1; then - echo "::notice::affinescript compiler unavailable on runner — skipping drift check" - exit 0 - fi - tmp="$(mktemp /tmp/check-ts-allowlist-drift.XXXXXX.deno.js)" - if ! affinescript compile --deno-esm -o "$tmp" .standards-checkout/scripts/check-ts-allowlist.affine; then - echo "::warning::affinescript compile failed — drift check skipped" - rm -f "$tmp" - exit 0 - fi - if diff -u .standards-checkout/scripts/check-ts-allowlist.deno.js "$tmp"; then - echo "✅ check-ts-allowlist .affine source and .deno.js compiled output are in sync" - else - echo "::warning::check-ts-allowlist.deno.js drifted from check-ts-allowlist.affine — re-run \`just check-ts-allowlist-drift\` locally and recommit the .deno.js" - fi - rm -f "$tmp" - # Shared escape hatch for the banned-language-file checks below. # Honours three exemption mechanisms (see # standards/docs/EXEMPTION-MECHANISMS.adoc): diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index af04436ec..8f7172391 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -35,15 +35,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # check-ts-allowlist-test.sh executes the generated Deno target, and the - # scorecard grounding suite runs pass-checks that use the same toolchain. - # Without installing Deno, the suite reported 18 assertion failures as - # one red test file and also made the scorecard fixtures fail. - - name: Install Deno test runtime - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 - with: - deno-version: v2.x - # PyYAML is required by the secret-scanner canary. The scorecard # grounding tests execute the same checks as registry-verify, including # checks that require ripgrep and xmllint. diff --git a/Justfile b/Justfile index 2360c4a27..0400b9f88 100644 --- a/Justfile +++ b/Justfile @@ -247,19 +247,6 @@ help-me: @echo "Include the output of 'just doctor' in your report." -# Verify scripts/check-ts-allowlist.deno.js matches what compiling -# scripts/check-ts-allowlist.affine produces. Run after editing the -# .affine source. Exit 0 = in sync; non-zero with diff = drifted. -# See standards#312. -check-ts-allowlist-drift: - @command -v affinescript >/dev/null 2>&1 || { echo "affinescript compiler not on PATH — skipping drift check"; exit 0; } - @tmp="$$(mktemp /tmp/check-ts-allowlist-drift.XXXXXX.deno.js)"; \ - affinescript compile --deno-esm -o "$$tmp" scripts/check-ts-allowlist.affine; \ - diff -u scripts/check-ts-allowlist.deno.js "$$tmp"; \ - rc=$$?; \ - rm -f "$$tmp"; \ - exit $$rc - # Print the current CRG grade (reads from READINESS.md '**Current Grade:** X' line) crg-grade: @grade=$$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ diff --git a/scripts/check-ts-allowlist.affine b/scripts/check-ts-allowlist.affine deleted file mode 100644 index 7d53ef033..000000000 --- a/scripts/check-ts-allowlist.affine +++ /dev/null @@ -1,245 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Ported via Harvard Engine (Semantic pass) - -module check-ts-allowlist; - -// SPDX-License-Identifier: MPL-2.0 -// 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 -// 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 unknown -// 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 unknown non-exempt files remain; -// otherwise print the success line. -// -// Permission scope is `--allow-read` only. No network, no env, no write. - -let DIR_NAMES_ALLOWED = new Set([ - "bindings", "tests", "test", "scripts", - "mcp-adapter", "cli", "vendor", "examples", "ffi", - "node_modules", "benchmarks", -]); - -fn builtinAllowed(p: string): boolean { - if (p.endsWith(".d.ts")) return true; - let 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; - let segs = p.split("/"); - for (let i = 0; i < segs.length - 1; i++) { - let 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; -} - -fn globToRegex(g: string): RegExp { - // The Python implementation stripped a leading "./" via `.lstrip('./')` - // which is a multi-char strip (unknown 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 = ""; - let 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 + "$"); -} - -struct Exemption { raw: string; rx: RegExp; } - -fn normalizeRepoPath(p: string): string { - let out = p.trim(); - while (out.length > 0 && (out[0] === "." || out[0] === "/")) { - out = out.slice(1); - } - return out; -} - -fn normalizeExemptionCell(cell: string): string { - let out = cell.trim(); - let codeSpan = out.match(/^`([^`]+)`$/) ?? out.match(/^`([^`]+)`/); - if (codeSpan) { - out = codeSpan[1].trim(); - } - return normalizeRepoPath(out); -} - -fn nonExemptionCell(cell: string): boolean { - return cell === "" || /^:?-{3,}:?$/.test(cell) || /^path\b/i.test(cell); -} - -async fn loadExemptionsFromClaudeMd(): Exemption[] { - // Layer 2 — heading-table exemptions parsed from `.claude/CLAUDE.md`. - // - // Heading regex relaxation (was: literal `TypeScript [Ee]xemptions`): - // now matches unknown markdown heading containing the substring sequence - // (TypeScript|JavaScript|TS|JS|.tsx?) … Exemption(s). Picks up - // `### TypeScript / JavaScript Exemptions (Approved)` (the - // affinescript form), the singular `### TypeScript Exemption`, and - // `.ts` / `.tsx`-mentioning variants. Anchored to a markdown heading - // prefix so prose mentions of the phrase elsewhere in the file do - // NOT trigger table parsing. - // - // Multi-table support: scans every heading; on hitting unknown heading - // that's NOT an exemption-section heading we leave table-mode (the - // original "break on first heading" was correct for the heredoc but - // a multi-section file would miss the second exemption table). - const exemptions: Exemption[] = []; - let text: string; - try { - text = await Deno.readTextFile(".claude/CLAUDE.md"); - } catch { - return exemptions; - } - let tsHeading = - /^#{1,4}\s+.*(?:TypeScript|JavaScript|TS|JS|\.tsx?)\b[^#\n]*[Ee]xemption/; - let anyHeading = /^#{1,4}\s/; - let inTable = false; - for (const line of text.split("\n")) { - if (tsHeading.test(line)) { - inTable = true; - continue; - } - if (inTable && anyHeading.test(line)) { - // A different heading — leave table mode but keep scanning for - // another exemption section in the same file. - inTable = false; - continue; - } - let tableLine = line.trim(); - if (inTable && tableLine.startsWith("|")) { - let cells = tableLine.split("|"); - if (cells.length >= 3) { - let raw = normalizeExemptionCell(cells[1]); - if (!nonExemptionCell(raw)) { - exemptions.push({ raw, rx: globToRegex(raw) }); - } - } - } - } - return exemptions; -} - -async fn loadExemptionsFromAllowlistFile(): Exemption[] { - // Layer 2.5 — optional plain-text allowlist at the repo root. - // One glob per line. Lines starting with `#` are comments; blank - // lines are ignored. Decouples gate-pass from documentation prose - // (the CLAUDE.md heading-table is the documented variant; this - // file is the typed-infrastructure variant). Both sources merge - // additively — either alone is sufficient. - const exemptions: Exemption[] = []; - let text: string; - try { - text = await Deno.readTextFile(".governance-allowlist"); - } catch { - return exemptions; - } - for (const rawLine of text.split("\n")) { - let line = normalizeExemptionCell(rawLine); - if (line === "" || line.startsWith("#")) continue; - exemptions.push({ raw: line, rx: globToRegex(line) }); - } - return exemptions; -} - -async fn loadExemptions(): Exemption[] { - let fromCm = await loadExemptionsFromClaudeMd(); - let fromAllow = await loadExemptionsFromAllowlistFile(); - return [...fromCm, ...fromAllow]; -} - -fn exempt(p: string, exemptions: Exemption[]): boolean { - let target = normalizeRepoPath(p); - for (const e of exemptions) { - if (e.rx.test(target)) return true; - let bare = normalizeRepoPath(e.raw); - if (target === bare) return true; - if (bare.endsWith("/") && target.startsWith(bare)) return true; - } - return false; -} - -fn 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)) { - let name = entry.name; - // Skip dotfiles/dotted dirs (matching Python's check on path parts). - if (name.startsWith(".") && name !== "." && name !== "..") continue; - let full = dir === "." ? name : `${dir}/${name}`; - if (entry.isDirectory) { - yield* walkTs(full); - } else if (entry.isFile) { - if (isTypeScriptArtifact(name)) { - yield full; - } - } - } -} - -async fn main() { - let exemptions = await loadExemptions(); - const found: string[] = []; - for await (const f of walkTs(".")) { - found.push(f); - } - let 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 a 'TypeScript Exemptions' table in .claude/CLAUDE.md (Layer 2)"); - console.log(" (d) add a line to .governance-allowlist at the repo root (Layer 2.5 — typed infrastructure file)"); - console.log(""); - console.log("See docs/EXEMPTION-MECHANISMS.adoc for the full mechanism reference."); - if (exemptions.length > 0) { - console.log(`\n(Currently ${exemptions.length} exemption(s) parsed across both layers.)`); - } - Deno.exit(1); - } - console.log(`✅ No TypeScript files outside allowlist (${exemptions.length} per-repo exemption(s) parsed across CLAUDE.md + .governance-allowlist).`); -} - -if (import.meta.main) { - await main(); -} - diff --git a/scripts/check-ts-allowlist.deno.js b/scripts/check-ts-allowlist.deno.js deleted file mode 100644 index b1aa6fa2f..000000000 --- a/scripts/check-ts-allowlist.deno.js +++ /dev/null @@ -1,360 +0,0 @@ -// 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" }; -const Ok = (value) => ({ tag: "Ok", value }); -const Err = (error) => ({ tag: "Err", error }); -const Unit = null; -const print = (s) => { Deno.stdout.writeSync(new TextEncoder().encode(String(s))); }; -const println = (s) => { console.log(String(s)); }; -// ---- Deno host shims (extern fn lowering targets, issue #122) ---- -// Kept tiny + inlined so emitted modules are genuinely drop-in (no extra -// package to publish or resolve). The same surface is mirrored, for -// standalone `deno test`, by packages/affine-deno/mod.js. -const __as_ensureDir = (p) => { - try { Deno.mkdirSync(p, { recursive: true }); } - catch (e) { if (!(e instanceof Deno.errors.AlreadyExists)) throw e; } -}; -const __as_pathJoin = (a, b) => { - if (a.length === 0) return b; - const sep = a.endsWith("/") || a.endsWith("\\") ? "" : "/"; - return a + sep + b; -}; -const __as_readDirNames = (p) => { - const names = []; - for (const entry of Deno.readDirSync(p)) { - if (entry.isFile) names.push(entry.name); - } - return names; -}; -const __as_isNotFound = (e) => (e instanceof Deno.errors.NotFound); -const __as_walkRecursive = (root) => { - const out = []; - const rec = (dir) => { - for (const entry of Deno.readDirSync(dir)) { - const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; - if (entry.isFile) out.push(full); - else if (entry.isDirectory) rec(full); - } - }; - rec(root); - return out; -}; -const __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); -const __as_wasmInstance = (bytes) => - new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; -const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); -// ---- motion (bindings #4): consumer-provided import ---- -// Host JS environment must expose globalThis.__as_motion (the motion -// library or a compatible mock). Tests set it in the harness before -// importing the generated module; production consumers typically do -// `import * as m from "motion"; globalThis.__as_motion = m;` once at -// module-init time. The AffineScript-side externs (stdlib/Motion.affine) -// don't see this indirection — they call __as_motion* helpers directly. -const __as_motionAnimate = (target, keyframes, options) => - globalThis.__as_motion.animate(target, keyframes, options); -const __as_motionAwait = (controls) => - Promise.resolve(controls).then(() => 0); -const __as_motionCancel = (controls) => { - if (controls && typeof controls.cancel === "function") controls.cancel(); - return 0; -}; -// `animateMini` / `tween` / `spring` / `ease` — bindings #4 follow-up -// surface. Each helper resolves the host method on globalThis.__as_motion -// at call time so a mock that only stubs a subset still works for the -// rest (the smoke harness exercises every variant). -const __as_motionAnimateMini = (target, keyframes, options) => - globalThis.__as_motion.animateMini(target, keyframes, options); -const __as_motionTween = (target, from, to, options) => - globalThis.__as_motion.tween(target, from, to, options); -const __as_motionSpring = (target, keyframes, springConfig) => - globalThis.__as_motion.spring(target, keyframes, springConfig); -const __as_motionEase = (name) => - globalThis.__as_motion.ease(name); -// ---- pixi.js (bindings #1): consumer-provided import ---- -// Host JS environment exposes globalThis.__as_pixi (the PIXI namespace -// from `import * as PIXI from "pixi.js"`). Tests set it in the harness -// before importing the generated module. -const __as_pixiAppInit = async (options) => { - const app = new globalThis.__as_pixi.Application(); - await app.init(options); - return app; -}; -const __as_pixiAppCanvas = (app) => app.canvas; -const __as_pixiAppStage = (app) => app.stage; -const __as_pixiAppTicker = (app) => app.ticker; -const __as_pixiAppDestroy = (app) => { app.destroy(); return 0; }; -const __as_pixiContainerNew = () => new globalThis.__as_pixi.Container(); -const __as_pixiContainerAddChild = (p, c) => { p.addChild(c); return 0; }; -const __as_pixiContainerRemoveChild = (p, c) => { p.removeChild(c); return 0; }; -const __as_pixiContainerSetPosition = (c, x, y) => { c.x = x; c.y = y; return 0; }; -const __as_pixiContainerSetVisible = (c, v) => { c.visible = v; return 0; }; -const __as_pixiContainerDestroy = (c) => { c.destroy(); return 0; }; -const __as_pixiSpriteFrom = (t) => new globalThis.__as_pixi.Sprite(t); -// Upcasts are identity — PIXI's class hierarchy makes Sprite/Graphics/ -// Text actual Container subclasses, so the JS object is the same. -const __as_pixiSpriteAsContainer = (s) => s; -const __as_pixiTextureFromUrl = (url) => globalThis.__as_pixi.Texture.from(url); -const __as_pixiGraphicsNew = () => new globalThis.__as_pixi.Graphics(); -const __as_pixiGraphicsRect = (g, x, y, w, h) => { g.rect(x, y, w, h); return 0; }; -const __as_pixiGraphicsFill = (g, color) => { g.fill({ color }); return 0; }; -const __as_pixiGraphicsClear = (g) => { g.clear(); return 0; }; -const __as_pixiGraphicsAsContainer = (g) => g; -const __as_pixiTextNew = (options) => new globalThis.__as_pixi.Text(options); -const __as_pixiTextSetText = (t, content) => { t.text = content; return 0; }; -const __as_pixiTextAsContainer = (t) => t; -const __as_pixiTickerAdd = (t, cb) => { t.add(cb); return 0; }; -const __as_pixiTickerStart = (t) => { t.start(); return 0; }; -const __as_pixiTickerStop = (t) => { t.stop(); return 0; }; -// ---- @pixi/ui (bindings #3): consumer-provided import ---- -// Host JS environment exposes globalThis.__as_pixi_ui (the namespace -// from `import * as PixiUI from "@pixi/ui"`). Tests set it in the -// harness before importing the generated module; production -// consumers typically do once at module-init time. The -// AffineScript-side externs (stdlib/PixiUI.affine) don't see this -// indirection — they call __as_pixiUi* helpers directly. -// -// Upcasts to Container are identity — @pixi/ui's Button / -// FancyButton / Slider / Switch are all real PIXI.Container -// subclasses, so the JS object is the same. -const __as_pixiUiButtonNew = (options) => new globalThis.__as_pixi_ui.Button(options); -const __as_pixiUiButtonOnPress = (b, cb) => { b.onPress.connect(cb); return 0; }; -const __as_pixiUiButtonAsContainer = (b) => b; -const __as_pixiUiFancyButtonNew = (options) => new globalThis.__as_pixi_ui.FancyButton(options); -const __as_pixiUiFancyButtonAsContainer = (b) => b; -const __as_pixiUiSliderNew = (options) => new globalThis.__as_pixi_ui.Slider(options); -const __as_pixiUiSliderOnUpdate = (s, cb) => { s.onUpdate.connect(cb); return 0; }; -const __as_pixiUiSliderAsContainer = (s) => s; -const __as_pixiUiSwitchNew = (options) => new globalThis.__as_pixi_ui.Switch(options); -const __as_pixiUiSwitchOnChange = (sw, cb) => { sw.onChange.connect(cb); return 0; }; -const __as_pixiUiSwitchAsContainer = (sw) => sw; -// ---- @pixi/sound (bindings #2): consumer-provided import ---- -// Host JS environment exposes globalThis.__as_pixi_sound (the `Sound` -// named export from `@pixi/sound`). Tests set it in the harness before -// importing the generated module; production consumers typically do -// `import { Sound } from "@pixi/sound"; globalThis.__as_pixi_sound = Sound;` -// once at module-init time. The AffineScript-side externs -// (stdlib/PixiSound.affine) don't see this indirection — they call -// __as_pixiSound* helpers directly. -const __as_pixiSoundFrom = (url) => globalThis.__as_pixi_sound.from(url); -const __as_pixiSoundPlay = (s) => { s.play(); return 0; }; -const __as_pixiSoundStop = (s) => { s.stop(); return 0; }; -const __as_pixiSoundPause = (s) => { s.pause(); return 0; }; -const __as_pixiSoundResume = (s) => { s.resume(); return 0; }; -const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; -const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; -// `++` is overloaded (string concat / array concat); `a + b` would -// stringify arrays. Dispatch on shape so stdlib/string.affine's -// `result ++ [x]` and `a ++ b` are both correct. -const __as_concat = (a, b) => Array.isArray(a) ? a.concat(b) : (a + b); -// Honest host/runtime primitives underpinning the AffineScript-level -// stdlib/string.affine (its is_empty/starts_with/ends_with/split/join/ -// replace/... are real AffineScript on top of these). -const __as_strSub = (s, start, n) => String(s).slice(start, start + n); -const __as_strGet = (s, i) => String(s)[i]; -const __as_strFind = (s, n) => String(s).indexOf(n); -const __as_charToInt = (c) => String(c).codePointAt(0); -const __as_intToChar = (n) => String.fromCodePoint(n); -const __as_parseInt = (s) => { - const n = parseInt(String(s), 10); - return Number.isNaN(n) ? None : Some(n); -}; -const __as_parseFloat = (s) => { - const n = parseFloat(String(s)); - return Number.isNaN(n) ? None : Some(n); -}; -const __as_show = (v) => (typeof v === "string" ? v : JSON.stringify(v)); -// ---- Http (issue #160): portable fetch round-trip ---- -// `headers` crosses the boundary as an AffineScript [(String, String)] -// assoc list == JS array of [name, value] pairs. `body` is an -// AffineScript Option == { tag: "Some", value } | { tag: "None" }. -// The result is the `Response` record shape { status, headers, body }. -const __as_httpHeadersToObject = (pairs) => { - const o = {}; - for (const kv of (pairs || [])) o[kv[0]] = kv[1]; - return o; -}; -const __as_httpHeadersFromResponse = (res) => { - const out = []; - res.headers.forEach((value, key) => out.push([key, value])); - return out; -}; -// ---- hpm-json-rsr Zig FFI shims (stdlib/json.affine v0.3) ---- -// `HpmJsonValue` is opaque to AffineScript; on Deno-ESM it's just the -// underlying JS value from JSON.parse. The shims mirror the sentinel -// conventions of the Zig exports so the AffineScript-side wrappers -// (`to_json`, `parse`) behave identically across backends. -const __as_hpmJsonParse = (s) => { - try { return Some(JSON.parse(String(s))); } catch (_e) { return None; } -}; -const __as_hpmJsonFree = (_v) => 0; -const __as_hpmJsonType = (v) => { - if (v === null || v === undefined) return 0; - if (typeof v === "boolean") return 1; - if (typeof v === "number") return Number.isInteger(v) ? 2 : 3; - if (typeof v === "string") return 4; - if (Array.isArray(v)) return 5; - if (typeof v === "object") return 6; - return -1; -}; -const __as_hpmJsonBool = (v) => (typeof v === "boolean" ? (v ? 1 : 0) : -1); -const __as_hpmJsonInt = (v) => - (typeof v === "number" ? Math.trunc(v) : Number.MIN_SAFE_INTEGER); -const __as_hpmJsonFloat = (v) => (typeof v === "number" ? v : NaN); -const __as_hpmJsonString = (v) => (typeof v === "string" ? v : ""); -const __as_hpmJsonObjectGet = (v, k) => { - if (v === null || typeof v !== "object" || Array.isArray(v)) return None; - return Object.prototype.hasOwnProperty.call(v, String(k)) - ? Some(v[String(k)]) : None; -}; -const __as_hpmJsonArrayLen = (v) => (Array.isArray(v) ? v.length : 0); -const __as_hpmJsonArrayGet = (v, i) => { - if (!Array.isArray(v)) return None; - const idx = Number(i); - return (idx >= 0 && idx < v.length) ? Some(v[idx]) : None; -}; -const __as_hpmJsonEscapeString = (s) => { - let out = ""; - const src = String(s); - for (let i = 0; i < src.length; i++) { - const c = src.charCodeAt(i); - if (c === 0x22) out += "\\\""; - else if (c === 0x5c) out += "\\\\"; - else if (c === 0x0a) out += "\\n"; - else if (c === 0x0d) out += "\\r"; - else if (c === 0x09) out += "\\t"; - else if (c === 0x08) out += "\\b"; - else if (c === 0x0c) out += "\\f"; - else if (c < 0x20) out += "\\u00" + c.toString(16).padStart(2, "0"); - else out += src[i]; - } - return out; -}; -const __as_httpFetch = async (url, method, headers, bodyOpt) => { - const init = { method, headers: __as_httpHeadersToObject(headers) }; - if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value; - // `globalThis.fetch` explicitly: the stdlib `Http.fetch` compiles to a - // module-level `function fetch`, which would otherwise shadow the host. - const res = await globalThis.fetch(url, init); - const text = await res.text(); - return { - status: res.status, - headers: __as_httpHeadersFromResponse(res), - body: text, - }; -}; -// ---- end runtime ---- - -export function split(s, delimiter) { - const slen = ((s).length); - const dlen = ((delimiter).length); - if ((dlen === 0)) { let result = []; let i = 0; while ((i < slen)) { result = __as_concat(result, [__as_strSub(s, i, 1)]); i = (i + 1); } return result; } - let result = []; - let current_start = 0; - let i = 0; - while ((i <= (slen - dlen))) { if ((__as_strSub(s, i, dlen) === delimiter)) { result = __as_concat(result, [__as_strSub(s, current_start, (i - current_start))]); current_start = (i + dlen); i = (i + dlen); } else { i = (i + 1); } } - result = __as_concat(result, [__as_strSub(s, current_start, (slen - current_start))]); - return result; -} - -function ends_with(s, suffix) { - const slen = ((s).length); - const sfxlen = ((suffix).length); - return ((sfxlen > slen) ? (() => { return false; })() : (() => { return (__as_strSub(s, (slen - sfxlen), sfxlen) === suffix); })()); -} - -const DIR_NAMES_ALLOWED = ["bindings", "tests", "test", "scripts", "mcp-adapter", "cli", "vendor", "examples", "ffi", "node_modules", "benchmarks"]; -function builtinAllowed(p) { - if (ends_with(p, ".d.ts")) { return true; } - const segs = split(p, "/"); - const segs_len = ((segs).length); - const base = segs[(segs_len - 1)]; - if ((base === "mod.ts")) { return true; } - if (((((base === "lsp-server.ts") || (base === "lsp_server.ts")) || (base === "lsp.ts")) || ends_with(base, "-lsp.ts"))) { return true; } - if ((ends_with(base, ".bench.ts") || ends_with(base, "_bench.ts"))) { return true; } - let i = 0; - while ((i < (segs_len - 1))) { const s = segs[i]; let j = 0; const dn_len = ((DIR_NAMES_ALLOWED).length); while ((j < dn_len)) { if ((s === DIR_NAMES_ALLOWED[j])) { return true; } j = (j + 1); } if (__as_regexMatch(s, "vscode")) { return true; } if (__as_regexMatch(s, "^deno-")) { return true; } i = (i + 1); } - return false; -} - -function globToRegex(g) { - let g2 = g; - while (((((g2).length) > 0) && ((__as_strSub(g2, 0, 1) === ".") || (__as_strSub(g2, 0, 1) === "/")))) { g2 = __as_strSub(g2, 1, (((g2).length) - 1)); } - let out = ""; - let i = 0; - const g2_len = ((g2).length); - while ((i < g2_len)) { const c = __as_strSub(g2, i, 1); if ((c === "*")) { out = __as_concat(out, ".*"); } else { if ((c === "?")) { out = __as_concat(out, "."); } else { if (((((((((((((c === ".") || (c === "+")) || (c === "(")) || (c === ")")) || (c === "{")) || (c === "}")) || (c === "[")) || (c === "]")) || (c === "|")) || (c === "^")) || (c === "$")) || (c === "\\"))) { out = __as_concat(__as_concat(out, "\\"), c); } else { out = __as_concat(out, c); } } } i = (i + 1); } - return __as_concat(__as_concat("^", out), "$"); -} - -// 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 ""; } })(); - if ((text === "")) { return exemptions; } - const tsHeading = "^#{1,4}\\s+.*(?:TypeScript|JavaScript|TS|JS|\\.tsx?)\\b[^#\\n]*[Ee]xemption"; - const anyHeading = "^#{1,4}\\s"; - let inTable = false; - 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))) { 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; -} - -function loadExemptionsFromAllowlistFile() { - let exemptions = []; - const text = (() => { try { return (() => { return Deno.readTextFileSync(".governance-allowlist"); })(); } catch (__e) { return ""; } })(); - if ((text === "")) { return exemptions; } - const lines = split(text, "\n"); - let i = 0; - const lines_len = ((lines).length); - 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; -} - -function loadExemptions() { - return __as_concat(loadExemptionsFromClaudeMd(), loadExemptionsFromAllowlistFile()); -} - -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(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; -} - -export function main() { - const exemptions = loadExemptions(); - let found = []; - 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 (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); - while ((k < found_len)) { const f = found[k]; if (((!builtinAllowed(f)) && (!isExempt(f, exemptions)))) { bad = __as_concat(bad, [f]); } k = (k + 1); } - if ((((bad).length) > 0)) { (console.error("\u274C TypeScript files detected outside the allowlist.\n"), 0); let m = 0; const bad_len = ((bad).length); while ((m < bad_len)) { const f = bad[m]; (console.error(__as_concat(" ", f)), 0); m = (m + 1); } (console.error(""), 0); (console.error("To resolve, choose one:"), 0); (console.error(" (a) migrate the file to AffineScript"), 0); (console.error(" (b) move to an allowlisted bridge path"), 0); (console.error(" (c) add an entry to a 'TypeScript Exemptions' table in .claude/CLAUDE.md (Layer 2)"), 0); (console.error(" (d) add a line to .governance-allowlist at the repo root (Layer 2.5 \u2014 typed infrastructure file)"), 0); (console.error(""), 0); (console.error("See docs/EXEMPTION-MECHANISMS.adoc for the full mechanism reference."), 0); if ((((exemptions).length) > 0)) { (console.error(__as_concat(__as_concat("\n(Currently ", String(((exemptions).length))), " exemption(s) parsed across both layers.)")), 0); } return Deno.exit(1); } - println(__as_concat(__as_concat("\u2705 No TypeScript files outside allowlist (", String(((exemptions).length))), " per-repo exemption(s) parsed across CLAUDE.md + .governance-allowlist).")); - return 0; -} - -await main(); diff --git a/scripts/check-ts-allowlist.sh b/scripts/check-ts-allowlist.sh new file mode 100755 index 000000000..86924cbf9 --- /dev/null +++ b/scripts/check-ts-allowlist.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan Jewell +# +# check-ts-allowlist.sh — fail when hand-authored TypeScript appears outside +# the allowlist. Runs against the current working directory. +# +# WHY SHELL. This replaces check-ts-allowlist.deno.js, which put a Deno install +# step on a REQUIRED context on every estate repo. The obvious replacement was +# AffineScript compiled to Bun, but scripts/check-ts-allowlist.affine is +# TypeScript wearing an .affine extension: it has never compiled, so the .js +# beside it was never generated from it, and stdlib/Bun.affine declares no +# filesystem capability to port onto. Shell needs no runtime beyond the tools +# every runner already has — the same reasoning recorded in the header of +# scripts/check-workflow-duplicate-keys.sh. +# +# Behaviour is pinned by scripts/tests/check-ts-allowlist-test.sh, which +# asserts identical verdicts to the Deno implementation on every case. + +set -uo pipefail + +DIR_NAMES_ALLOWED=(bindings tests test scripts mcp-adapter cli vendor examples ffi node_modules benchmarks) + +# Strip leading "." and "/" characters, as the Deno implementation does, so +# "./src/a.ts", "/src/a.ts" and "src/a.ts" are one path. +normalize_repo_path() { + local out="$1" + out="${out#"${out%%[![:space:]]*}"}" # ltrim + out="${out%"${out##*[![:space:]]}"}" # rtrim + while [ -n "$out" ]; do + case "$out" in + .*|/*) out="${out#?}" ;; + *) break ;; + esac + done + printf '%s' "$out" +} + +# Glob -> anchored ERE. '*' is any run, '?' is one character, everything else +# regex-significant is escaped. +glob_to_regex() { + local g out="" i c + g="$(normalize_repo_path "$1")" + for (( i = 0; i < ${#g}; i++ )); do + c="${g:i:1}" + case "$c" in + '*') out+='.*' ;; + '?') out+='.' ;; + '.'|'+'|'('|')'|'{'|'}'|'['|']'|'^'|'$'|'|') out+="\\$c" ;; + $'\\') out+=$'\\\\' ;; + *) out+="$c" ;; + esac + done + printf '^%s$' "$out" +} + +# --- exemption sources ------------------------------------------------------- +# Layer 2: a "TypeScript Exemptions" table in .claude/CLAUDE.md +# Layer 2.5: one path per line in .governance-allowlist +EX_RAW=() + +load_exemptions_from_claude_md() { + [ -f .claude/CLAUDE.md ] || return 0 + local ts_heading='^#{1,4}[[:space:]]+.*(TypeScript|JavaScript|TS|JS|\.tsx?)\b[^#]*[Ee]xemption' + local any_heading='^#{1,4}[[:space:]]' + local in_table=0 line rest raw + while IFS= read -r line || [ -n "$line" ]; do + if [[ $line =~ $ts_heading ]]; then in_table=1; continue; fi + if [ "$in_table" -eq 1 ] && [[ $line =~ $any_heading ]]; then in_table=0; continue; fi + [ "$in_table" -eq 1 ] || continue + [ -n "$line" ] || continue + # a row whose first cell is a backticked path + [[ $line =~ ^[[:space:]]*\|[[:space:]]*\`[^\`]+\` ]] || continue + rest="${line#*\`}" # drop up to the first backtick + raw="${rest%%\`*}" # take up to the next one + [ -n "$raw" ] && EX_RAW+=("$raw") + done < .claude/CLAUDE.md +} + +load_exemptions_from_allowlist_file() { + [ -f .governance-allowlist ] || return 0 + local line raw + while IFS= read -r line || [ -n "$line" ]; do + raw="$(normalize_repo_path "$line")" + [ -n "$raw" ] || continue + case "$raw" in '#'*) continue ;; esac + EX_RAW+=("$raw") + done < .governance-allowlist +} + +is_exempt() { + local target rx bare e + target="$(normalize_repo_path "$1")" + for e in ${EX_RAW+"${EX_RAW[@]}"}; do + rx="$(glob_to_regex "$e")" + [[ $target =~ $rx ]] && return 0 + bare="$(normalize_repo_path "$e")" + [ "$target" = "$bare" ] && return 0 + case "$bare" in */) case "$target" in "$bare"*) return 0 ;; esac ;; esac + done + return 1 +} + +# --- builtin allowlist ------------------------------------------------------- +builtin_allowed() { + local p="$1" base seg d + base="${p##*/}" + case "$p" in *.d.ts) return 0 ;; esac + case "$base" in + mod.ts|lsp-server.ts|lsp_server.ts|lsp.ts) return 0 ;; + *-lsp.ts|*.bench.ts|*_bench.ts) return 0 ;; + esac + # any DIRECTORY segment (every segment but the last) + local dirpart="${p%/*}" + [ "$dirpart" = "$p" ] && return 1 + local IFS='/' + for seg in $dirpart; do + [ -n "$seg" ] || continue + for d in "${DIR_NAMES_ALLOWED[@]}"; do + [ "$seg" = "$d" ] && return 0 + done + case "$seg" in *vscode*) return 0 ;; deno-*) return 0 ;; esac + done + return 1 +} + +# A path is skipped entirely when any segment is a dotfile/dotdir (but "." and +# ".." are not dotfiles). +has_hidden_segment() { + local p="$1" seg + local IFS='/' + for seg in $p; do + [ -n "$seg" ] || continue + [ "$seg" = "." ] && continue + [ "$seg" = ".." ] && continue + case "$seg" in .*) return 0 ;; esac + done + return 1 +} + +# --- main -------------------------------------------------------------------- +load_exemptions_from_claude_md +load_exemptions_from_allowlist_file + +bad=() +while IFS= read -r f; do + [ -n "$f" ] || continue + has_hidden_segment "$f" && continue + n="$(normalize_repo_path "$f")" + builtin_allowed "$n" && continue + is_exempt "$n" && continue + bad+=("$n") +done < <(find . -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.ts.bak' -o -name '*.tsx.bak' \) 2>/dev/null | LC_ALL=C sort) + +if [ "${#bad[@]}" -gt 0 ]; then + printf '%s\n' "❌ TypeScript files detected outside the allowlist." >&2 + printf '\n' >&2 + for f in "${bad[@]}"; do printf ' %s\n' "$f" >&2; done + printf '\n' >&2 + printf '%s\n' "To resolve, choose one:" >&2 + printf '%s\n' " (a) migrate the file to AffineScript" >&2 + printf '%s\n' " (b) move to an allowlisted bridge path" >&2 + printf '%s\n' " (c) add an entry to a 'TypeScript Exemptions' table in .claude/CLAUDE.md (Layer 2)" >&2 + printf '%s\n' " (d) add a line to .governance-allowlist at the repo root (Layer 2.5 — typed infrastructure file)" >&2 + printf '\n' >&2 + printf '%s\n' "See docs/EXEMPTION-MECHANISMS.adoc for the full mechanism reference." >&2 + if [ "${#EX_RAW[@]}" -gt 0 ]; then + printf '\n(Currently %d exemption(s) parsed across both layers.)\n' "${#EX_RAW[@]}" >&2 + fi + exit 1 +fi + +printf '✅ No TypeScript files outside allowlist (%d per-repo exemption(s) parsed across CLAUDE.md + .governance-allowlist).\n' "${#EX_RAW[@]}" diff --git a/scripts/tests/check-ts-allowlist-test.sh b/scripts/tests/check-ts-allowlist-test.sh index 27cb32d7a..2140fa13a 100755 --- a/scripts/tests/check-ts-allowlist-test.sh +++ b/scripts/tests/check-ts-allowlist-test.sh @@ -2,19 +2,19 @@ # SPDX-License-Identifier: MPL-2.0 # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell # -# Regression test for the compiled scripts/check-ts-allowlist.deno.js artifact. -# The canonical source is check-ts-allowlist.affine, which is covered by the -# separate source/compile drift check. Each case constructs a fresh fixture tree -# under a tmpdir, runs the executable artifact 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 change cannot silently regress -# estate-wide policy. +# Regression test for scripts/check-ts-allowlist.sh, the hand-authored +# JavaScript/TypeScript gate that governance-reusable.yml runs on every estate +# repo. Each case constructs a fresh fixture tree under a tmpdir, runs the gate +# against it, and asserts exit code + key output substrings. Mirrors the +# behaviour the previous inline-Python step was relied on for, so a future +# change cannot silently regress estate-wide policy. The gate was a Deno +# artifact until 2026-09-04; it is now pure bash + awk with no JS runtime. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_TARGETS=( - "$SCRIPT_DIR/../check-ts-allowlist.deno.js" + "$SCRIPT_DIR/../check-ts-allowlist.sh" ) for target in "${SCRIPT_TARGETS[@]}"; do @@ -44,7 +44,7 @@ run_case() { for target in "${SCRIPT_TARGETS[@]}"; do set +e local out - out="$(cd "$tmp" && deno run --allow-read --no-lock "$target" 2>&1)" + out="$(cd "$tmp" && bash "$target" 2>&1)" local actual_exit=$? set -e From 1768113bb58eb55196a19581faa35a01f2f92f75 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:11:15 +0100 Subject: [PATCH 2/7] chore(actions-lock): drop the setup-deno pins the shell gate retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions.lock is enforced at workflow LOAD time, so a stale lock kills every consumer's governance run with jobs=0 and no annotation. Removing the `denoland/setup-deno` steps changes the pin set, so the lock must move in the same PR. Hand-edited, deliberately. `gh actions-lock` in bare UPDATE mode (the mode scripts/update-actions-lock.sh itself calls) was tried first and rejected: on this tree it exited 0 reporting "All 42 workflows valid" while * prepending `# This workflow is managed by gh actions-lock.` at line 1 of all 42 workflows, above the SPDX header and DUPLICATING the banner that already sat on line 2; * rewriting `uses: ./.github/actions/signed-push` to the invalid `uses: $/.github/actions/signed-push` spelling that update-actions-lock.sh's own comments warn about; and * leaving both genuinely-stale `setup-deno` rows in place — the exact drift it was run to fix. The verifier is trustworthy even though the updater is not, so the four removed regions are witnessed by `scripts/check-actions-lock-gate.sh`: `"valid": true`, rc=0, zero `stale` findings, and no workflow byte mutated. Rows for the deno-ci pair deleted in #729 are pruned at the same time. --- .github/workflows/actions.lock | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index a7f9781e8..fb983aa9c 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -26,10 +26,6 @@ workflows: '.github/workflows/codeql.yml': [] '.github/workflows/debt-measure.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - '.github/workflows/deno-ci-reusable.yml': - - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - '.github/workflows/deno-ci.yml': [] '.github/workflows/doc-format.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/dyadt-verify.yml': @@ -46,7 +42,6 @@ workflows: '.github/workflows/governance-reusable.yml': - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - 'editorconfig-checker/action-editorconfig-checker@840e866d93b8e032123c23bac69dece044d4d84c' - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' '.github/workflows/governance.yml': [] @@ -105,7 +100,6 @@ workflows: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/self-test.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' '.github/workflows/signed-push-smoke.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1' @@ -172,11 +166,6 @@ dependencies: repo_id: 772313726 uses: - 'actions/setup-python@v2' - 'denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed': - ref: 'v2.0.5' - commit: 'sha1-22d081ff2d3a40755e97629de92e3bcbfa7cf2ed' - owner_id: 42048915 - repo_id: 356423100 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772': ref: '6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' commit: 'sha1-6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' From 2babcd7d75dce9ccf6e1318bf326d92d11554165 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:11:15 +0100 Subject: [PATCH 3/7] chore(debt): re-measure after retiring Deno; document the shell gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both counters measured directly from the probes recorded in Debtfile.a2ml, not assumed: deno-residue 4 -> 0 RESOLVED (kept as an entry at 0 — debt leaves by reaching zero, not by deletion) deno-artefacts 3 -> 2 PAID, ceiling lowered 3 -> 2 No ceiling was raised. `run-debtfile.sh --write` reports 0 breached, and `check-debtfile-structure.sh` passes. deno-artefacts stops at 2, not 0. The survivors are docs/migrations/npm-to-deno-template/{INVENTORY-2026-05-30,MIGRATION}.adoc — a migration recipe still pointing repos at the retired runtime. Deleting them is on-plan but it is a docs deletion inside a PR that rewrites a REQUIRED context, which would make rollback coarser, so it is a separate follow-up. docs/EXEMPTION-MECHANISMS.adoc described the .affine -> .deno.js compile chain as live in three places; those now describe the shell gate, with the retirement recorded in the history list. asciidoctor --failure-level=WARN clean (the default is FATAL, which would have hidden real errors). --- .machine_readable/Debtfile.a2ml | 6 +++--- docs/EXEMPTION-MECHANISMS.adoc | 31 ++++++++++++++++--------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/.machine_readable/Debtfile.a2ml b/.machine_readable/Debtfile.a2ml index dcea99709..9585331bc 100644 --- a/.machine_readable/Debtfile.a2ml +++ b/.machine_readable/Debtfile.a2ml @@ -90,7 +90,7 @@ forgotten. ### deno-residue - description: Deno residue in this repository after the Bun ruling. `governance-reusable.yml` still runs `denoland/setup-deno`, which INSTALLS DENO ON EVERY ESTATE REPO ON EVERY RUN; `deno-ci{,-reusable}.yml` still ship the failing `deno / Deno CI`; `scripts/check-ts-allowlist.deno.js` is a worked Deno example in scripts/; and `docs/migrations/npm-to-deno-template/` is a live recipe pointing repos AT the retired runtime. Owner ruled Deno REMOVED and Bun permanent (said three times, reaffirmed 2026-08-07). Must reach 0. Excludes */bindings/deno/, which is interop for OTHER people's Deno code and a separate question. - probe: git grep -lE "denoland/setup-deno|deno run|deno test|deno fmt|deno lint" -- ".github/workflows/*.yml" "scripts/*" | wc -l -- count: 4 +- count: 0 - ceiling: 4 - severity: medium - policy: remediable @@ -100,8 +100,8 @@ forgotten. ### deno-artefacts - description: Files that exist only to serve Deno — the deno-ci workflow pair, the compiled check-ts-allowlist.deno.js, and the npm-to-deno migration template. Deleting these is the completion of the Bun migration, not a precondition of it. Excludes */bindings/deno/. - probe: git ls-files ".github/workflows/deno*" "scripts/*deno*" "docs/migrations/npm-to-deno-template/*" | wc -l -- count: 3 -- ceiling: 3 +- count: 2 +- ceiling: 2 - severity: medium - policy: remediable - tri: substitute diff --git a/docs/EXEMPTION-MECHANISMS.adoc b/docs/EXEMPTION-MECHANISMS.adoc index 524fe8769..ef7044460 100644 --- a/docs/EXEMPTION-MECHANISMS.adoc +++ b/docs/EXEMPTION-MECHANISMS.adoc @@ -169,10 +169,9 @@ across the estate. Three sub-layers: === 4a: Built-in path / filename allowlist -Hard-coded in `scripts/check-ts-allowlist.affine` (source of truth; -compiled to `scripts/check-ts-allowlist.deno.js` which the workflow -invokes). Covers paths that are *always* exempt regardless of per-repo -configuration: +Hard-coded in `scripts/check-ts-allowlist.sh`, which the governance +workflow invokes directly. Covers paths that are *always* exempt +regardless of per-repo configuration: * Directory segments: `bindings`, `tests`, `test`, `scripts`, `mcp-adapter`, `cli`, `vendor`, `examples`, `ffi`, `node_modules`, @@ -266,21 +265,23 @@ sufficient. Most repos will pick one or the other. This document seeds the doctrine. * AffineScript port (standards#283 seed, #310 compile/runtime fixes, #311 workflow swap): `.ts` → `.affine` self-referential port under - the TS→AffineScript campaign (#239 / #241 STEP 2). The `.ts` - archetype is kept for the regression suite and parallel-validation; - the workflow now runs the compiled `.deno.js`. Retirement of the - `.ts` is a follow-up after the dual-target window. + the TS→AffineScript campaign (#239 / #241 STEP 2). The workflow ran + the compiled `.deno.js`. +* Deno retirement (2026-09-04): the `.affine` source, its compiled + `.deno.js`, and the `deno run` workflow step were all deleted and + replaced by `scripts/check-ts-allowlist.sh` — pure bash + awk, so no + JS runtime is installed on any estate runner. The 18-case corpus was + run against both implementations first and gave identical verdicts on + every case. == Cross-references * `docs/HYPATIA-BASELINE-FORMAT.adoc` — the baseline file format. * `.machine_readable/hypatia-baseline.schema.json` — machine schema. -* `scripts/check-ts-allowlist.affine` — the AffineScript source of - truth for the Layer 4 detector (since standards#283 / #310 / #311). -* `scripts/check-ts-allowlist.deno.js` — the compiled artifact the - governance workflow runs. -* `scripts/check-ts-allowlist.ts` — the Deno archetype, retained as the - regression-suite target (`scripts/tests/check-ts-allowlist-test.sh`) - and for parallel-validation during the TS→AS dual-target window. +* `scripts/check-ts-allowlist.sh` — the bash + awk implementation of the + Layer 4 detector that the governance workflow runs (since 2026-09-04; + previously an AffineScript source compiled to a Deno artifact). +* `scripts/tests/check-ts-allowlist-test.sh` — the 18-case regression + corpus that pins its behaviour. * `hyperpolymath/standards#????` — proposal that landed this consumer. * `hyperpolymath/hypatia` — the scanner that emits findings. From df2e0ec772c5b24346d874bbfe1e491c3cf0c65a Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:22:14 +0100 Subject: [PATCH 4/7] fix(gates): resolve check-ts-allowlist locally on standards own PRs The shared-scripts checkout pins standards@main, so a script added in a PR is not yet visible to that PR own run -- the reusable governance job failed with exit 127 (bash: No such file or directory). Mirrors the sibling "Check language-policy invariants" fallback, but gates it on GITHUB_REPOSITORY being standards itself. Inside a reusable that variable names the CALLER, so no consumer repo can shadow this required gate with a permissive repo-local copy. Missing in both places stays a named error and a hard exit 1, never a silent pass. Proven with 5 cases run against the text extracted verbatim from the shipped YAML, including the consumer-shadowing rejection. --- .github/workflows/governance-reusable.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 59eb178bb..9f2184ed3 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -370,7 +370,22 @@ jobs: # Read-only execution; never writes outside the runner workspace. # Pure bash + awk, so no JS runtime is installed on the runner. # Source of truth: `scripts/check-ts-allowlist.sh` in standards. - run: bash .standards-checkout/scripts/check-ts-allowlist.sh + # The local fallback is for standards' OWN PRs: the checkout above + # pins standards@main, so a script added in a PR is not there yet. + # It is gated on the caller being standards, so no consumer repo can + # shadow this required gate with a permissive repo-local copy. + run: | + SCRIPT=".standards-checkout/scripts/check-ts-allowlist.sh" + if [ ! -f "$SCRIPT" ] && [ "$GITHUB_REPOSITORY" = "hyperpolymath/standards" ] \ + && [ -f scripts/check-ts-allowlist.sh ]; then + SCRIPT="scripts/check-ts-allowlist.sh" + echo "Using this repository's own copy (standards self-check)." + fi + if [ ! -f "$SCRIPT" ]; then + echo "::error::check-ts-allowlist gate not found in standards@main or locally" + exit 1 + fi + bash "$SCRIPT" - name: Check language-policy invariants run: | From 4f4d77d1b794d0d529b927135f67cd56848fedac Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:14:54 +0100 Subject: [PATCH 5/7] chore(debt): record gate-scripts-without-tests at its measured 30 PRs #735 and #736 each landed a test for a previously untested gate script, so the measurement dropped 32 -> 30 while the file still RECORDED 31. An under-record passes every PR gate in the repo: check-debtfile-structure.sh and check-debt-ratchet.sh both read the RECORDED number, and only run-debtfile.sh re-measures -- and that is confined to a weekly cron marked "MUST NOT be a required status check". So nothing on a PR would ever have noticed. Written by `run-debtfile.sh --write` (1 count update, 1 ceiling reduction), not by hand. deno-residue is deliberately left at count 0 / ceiling 4. The Deno retirement in this PR drove it to zero, and the ratchet asserts debt leaves by REACHING ZERO rather than by deletion, so the entry stays with its ceiling intact. Witnesses on this tree: run-debtfile.sh rc=0 (4 holding, 1 paid-down, 2 resolved, 0 breached); check-debtfile-structure.sh rc=0; check-debt-ratchet.sh vs origin/main rc=0. --- .machine_readable/Debtfile.a2ml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.machine_readable/Debtfile.a2ml b/.machine_readable/Debtfile.a2ml index 9585331bc..93d2b9598 100644 --- a/.machine_readable/Debtfile.a2ml +++ b/.machine_readable/Debtfile.a2ml @@ -39,8 +39,8 @@ forgotten. ### gate-scripts-without-tests - description: Scripts under scripts/ with no matching scripts/tests/-test.sh — a gate with no test has never been shown able to fail - probe: n=0; for f in $(git ls-files 'scripts/*.sh'); do b=$(basename "$f" .sh); case "$b" in *-test) continue;; esac; if [ ! -f "scripts/tests/${b}-test.sh" ] && [ ! -f "scripts/tests/${b#check-}-test.sh" ] && [ ! -f "scripts/tests/${b#run-}-test.sh" ]; then n=$((n+1)); fi; done; echo "$n" -- count: 31 -- ceiling: 31 +- count: 30 +- ceiling: 30 - severity: high - policy: remediable - tri: eliminate From 538e5a62736f847413014cef030c99d1732634c0 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:39:50 +0100 Subject: [PATCH 6/7] test(gates): cover the guards in verify-regextarget-claim.sh That script landed on main (#737) with no test, taking gate-scripts-without-tests from 30 to 31 and breaching its ceiling. Doctrine is to pay debt down rather than raise the ceiling, so this is the missing test rather than a new ceiling. Seven cases drive the script against a stub gitleaks and assert that each guard ABORTS rather than degrading to a silent skip: absent binary, version drift, and the three contaminated-control cases (zero findings, wrong rule, two findings). The documented GITLEAKS_ALLOW_VERSION_DRIFT=1 override is asserted to reach the measurement phase AND to still warn -- a silent override is how a version-specific result later gets quoted as if it were pinned. Case 3 deliberately expects a non-zero exit. A constant stub cannot satisfy the five regexTarget expectations that follow; only a real gitleaks can. Asserting success there would have meant teaching the stub to emulate gitleaks semantics, and the test would then measure the emulation instead of the script. Proven able to fail: neutering the version-mismatch abort reddens case 2 and only case 2. --- .../tests/verify-regextarget-claim-test.sh | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100755 scripts/tests/verify-regextarget-claim-test.sh diff --git a/scripts/tests/verify-regextarget-claim-test.sh b/scripts/tests/verify-regextarget-claim-test.sh new file mode 100755 index 000000000..bee3ef411 --- /dev/null +++ b/scripts/tests/verify-regextarget-claim-test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# Tests for verify-regextarget-claim.sh. +# +# ⚠ WHAT THIS FILE IS ACTUALLY FOR. verify-regextarget-claim.sh is itself a +# gate: it measures whether an anchored value regex suppresses a gitleaks +# `generic-api-key` finding, because the estate once believed it did not and +# steered every repo toward blunt `paths` allowlists on that false basis. A +# measuring gate is only as good as its ABORTS -- if it can be made to report a +# confident answer on a contaminated or mis-versioned instrument, it reproduces +# exactly the wrong belief it exists to disprove. +# +# So these cases do not test the gitleaks semantics (that is the script's own +# job, and it needs a real gitleaks). They test that the script REFUSES to +# answer when it cannot answer honestly. Every case drives the script with a +# STUB gitleaks, so the guards are exercised deterministically on any host, +# with or without gitleaks installed. +set -uo pipefail + +SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/verify-regextarget-claim.sh" +[ -x "$SCRIPT" ] || { echo "FATAL: $SCRIPT not found or not executable" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +pass=0 +fail=0 + +# A stub gitleaks. Reports $STUB_VERSION for `version`, and for `detect` writes +# $STUB_REPORT to whatever --report-path it was handed. +make_stub() { # make_stub + local p="$1" + cat > "$p" <<'STUB' +#!/usr/bin/env bash +if [ "${1:-}" = "version" ]; then printf '%s\n' "$STUB_VERSION"; exit 0; fi +rp="" +while [ $# -gt 0 ]; do + if [ "$1" = "--report-path" ]; then rp="${2:-}"; fi + shift +done +[ -n "$rp" ] && printf '%s' "$STUB_REPORT" > "$rp" +exit 0 +STUB + chmod +x "$p" + export STUB_VERSION="$2" + export STUB_REPORT="$3" +} + +check() { # check -- runs SCRIPT + local name="$1" want_rc="$2" want_txt="$3"; shift 3 + local out rc + out="$("$@" 2>&1)"; rc=$? + if [ "$rc" = "$want_rc" ] && printf '%s' "$out" | grep -qF "$want_txt"; then + printf ' ok %s\n' "$name"; pass=$((pass + 1)) + else + printf ' FAIL %s\n' "$name" + printf ' wanted rc=%s containing: %s\n' "$want_rc" "$want_txt" + printf ' got rc=%s: %s\n' "$rc" "$(printf '%s' "$out" | tr '\n' ' ' | cut -c1-160)" + fail=$((fail + 1)) + fi +} + +PINNED=8.18.4 +ONE_FINDING='[{"RuleID":"generic-api-key","Match":"Key : Ed25519_Private_Key;","Secret":"Ed25519_Private_Key"}]' + +# 1. No gitleaks at all must ABORT (rc 2), not silently skip. A gate that +# vanishes when its tool is absent is the estate's commonest fake green. +check "absent gitleaks aborts, does not skip" 2 "gitleaks not found" \ + env GITLEAKS="$TMP/does-not-exist" "$SCRIPT" + +# 2. A non-pinned gitleaks must ABORT. These results are version-specific; +# answering on an unpinned build is how a stale claim gets re-confirmed. +make_stub "$TMP/gl" "8.18.3" "$ONE_FINDING" +check "version drift aborts by default" 2 "CI pins $PINNED" \ + env GITLEAKS="$TMP/gl" STUB_VERSION=8.18.3 STUB_REPORT="$ONE_FINDING" "$SCRIPT" + +# 3. ...but the documented override must actually get PAST that guard, or the +# escape hatch is decorative. The assertion is deliberately narrow: it says +# the run reached the MEASUREMENT phase (it printed the control rule), not +# that the whole script succeeded. A constant stub cannot satisfy the five +# semantic expectations that follow -- only a real gitleaks can -- so the +# script correctly ends non-zero here, and asserting rc=0 would be a lie. +check "GITLEAKS_ALLOW_VERSION_DRIFT=1 reaches measurement" 1 "control: rule=generic-api-key" \ + env GITLEAKS="$TMP/gl" GITLEAKS_ALLOW_VERSION_DRIFT=1 \ + STUB_VERSION=8.18.3 STUB_REPORT="$ONE_FINDING" "$SCRIPT" + +# 3b. And the override must still WARN -- a silent override is how a +# version-specific result gets quoted later as if it were pinned. +check "override still warns about the drift" 1 "WARNING: measuring on 8.18.3" \ + env GITLEAKS="$TMP/gl" GITLEAKS_ALLOW_VERSION_DRIFT=1 \ + STUB_VERSION=8.18.3 STUB_REPORT="$ONE_FINDING" "$SCRIPT" + +# 4. THE CONTAMINATION GUARD -- the reason the script has a control at all. +# If the instrument reports zero findings for the control fixture, a WORKING +# allowlist entry is indistinguishable from an inert one, which is the most +# likely origin of the original false claim. It must abort, never measure. +check "control with 0 findings aborts (contaminated instrument)" 2 "control expected exactly 1 finding" \ + env GITLEAKS="$TMP/gl" STUB_VERSION="$PINNED" STUB_REPORT='[]' "$SCRIPT" + +# 5. Same for a control that fires the WRONG rule: the fixture is then no +# longer measuring generic-api-key, so every later count is off-target. +check "control firing the wrong rule aborts" 2 "control fired" \ + env GITLEAKS="$TMP/gl" STUB_VERSION="$PINNED" \ + STUB_REPORT='[{"RuleID":"aws-access-token","Match":"x","Secret":"x"}]' "$SCRIPT" + +# 6. Two findings is also contamination (config or report inside --source). +check "control with 2 findings aborts" 2 "control expected exactly 1 finding" \ + env GITLEAKS="$TMP/gl" STUB_VERSION="$PINNED" \ + STUB_REPORT='[{"RuleID":"generic-api-key","Match":"a","Secret":"a"},{"RuleID":"generic-api-key","Match":"b","Secret":"b"}]' \ + "$SCRIPT" + +echo +echo "=== SUMMARY ===" +echo "Pass: $pass" +echo "Fail: $fail" +[ "$fail" -eq 0 ] From 9fba1ede582c729bc8c6e2217ddbd741aaf2cd11 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:10:34 +0100 Subject: [PATCH 7/7] =?UTF-8?q?fix(gates):=20retain=20check-ts-allowlist.d?= =?UTF-8?q?eno.js=20as=20a=20shim=20=E2=80=94=20deleting=20it=20breaks=202?= =?UTF-8?q?69=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit governance-reusable.yml checks out standards' scripts/ at floating `ref: main` while each consumer pins the workflow YAML at a fixed SHA. Deleting a file under scripts/ therefore breaks every consumer whose pinned YAML still names it, with no red on the deleting PR — the breakage lands on other repos, later. Measured 2026-09-04 against the live pin census (1626 rows): 269 distinct repos pin a governance SHA that runs `deno run … scripts/check-ts-allowlist.deno.js`. The .ts-era SHAs have zero live consumers. So .deno.js and .affine are restored byte-identical to main as compatibility shims. Deno is NOT gone: this defers the owner's removal ruling to the repin campaign, it does not satisfy it. To make room within the existing ceilings rather than raise them, this also deletes docs/migrations/npm-to-deno-template/ — a live recipe pointing repos at the retired runtime, whose two inbound links were already dangling (both named MIGRATION.md after the .adoc rename). deno-artefacts 3 -> 1, deno-residue 1; both ceilings ratcheted down, none raised. Witnesses: check-ts-allowlist.sh green with the shim present; its suite 18/0; run-debtfile 0 breached; debtfile-structure ok; debt-ratchet ok vs origin/main; build-registry --check in sync; governance shape test 14/0. --- .claude/CLAUDE.md | 2 +- .machine_readable/Debtfile.a2ml | 12 +- docs/JS-RUNTIME-POLICY.adoc | 3 +- .../INVENTORY-2026-05-30.adoc | 91 ----- .../npm-to-deno-template/MIGRATION.adoc | 208 ---------- scripts/check-ts-allowlist.affine | 245 ++++++++++++ scripts/check-ts-allowlist.deno.js | 360 ++++++++++++++++++ 7 files changed, 613 insertions(+), 308 deletions(-) delete mode 100644 docs/migrations/npm-to-deno-template/INVENTORY-2026-05-30.adoc delete mode 100644 docs/migrations/npm-to-deno-template/MIGRATION.adoc create mode 100644 scripts/check-ts-allowlist.affine create mode 100644 scripts/check-ts-allowlist.deno.js diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1c6c08256..3bbaf4af9 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -282,7 +282,7 @@ Retired 2026-08-31: the bootstrap-shim row (`affinescript-deno-test/**`, `affine The hyperpolymath "npm banned" policy (2026-05-25) has the following approved exemptions on the hypatia rule `cicd_rules/nodejs_detected` (matches `package-lock.json`). -Migration substantially complete 2026-05-31 under umbrella `hyperpolymath/standards#253` (172 manifests at campaign start; all seven STEP issues #261/#262/#265/#268/#270/#273/#275 closed; ~22 physical-migration PRs landed plus three named-bucket audits closed `SUBSTANTIALLY DONE`; per-repo follow-up trackers cover the residual longtail). See `project_estate_npm_to_deno_2026_05_28.md`. Per-repo recipe: `docs/migrations/npm-to-deno-template/MIGRATION.md`. +Migration substantially complete 2026-05-31 under umbrella `hyperpolymath/standards#253` (172 manifests at campaign start; all seven STEP issues #261/#262/#265/#268/#270/#273/#275 closed; ~22 physical-migration PRs landed plus three named-bucket audits closed `SUBSTANTIALLY DONE`; per-repo follow-up trackers cover the residual longtail). See `project_estate_npm_to_deno_2026_05_28.md`. | Path / Pattern | Class | Rationale | Unblock condition | |---|---|---|---| diff --git a/.machine_readable/Debtfile.a2ml b/.machine_readable/Debtfile.a2ml index 93d2b9598..c3bd46da7 100644 --- a/.machine_readable/Debtfile.a2ml +++ b/.machine_readable/Debtfile.a2ml @@ -88,20 +88,20 @@ forgotten. - accepted-until: 2027-01-01 ### deno-residue -- description: Deno residue in this repository after the Bun ruling. `governance-reusable.yml` still runs `denoland/setup-deno`, which INSTALLS DENO ON EVERY ESTATE REPO ON EVERY RUN; `deno-ci{,-reusable}.yml` still ship the failing `deno / Deno CI`; `scripts/check-ts-allowlist.deno.js` is a worked Deno example in scripts/; and `docs/migrations/npm-to-deno-template/` is a live recipe pointing repos AT the retired runtime. Owner ruled Deno REMOVED and Bun permanent (said three times, reaffirmed 2026-08-07). Must reach 0. Excludes */bindings/deno/, which is interop for OTHER people's Deno code and a separate question. +- description: Deno residue in this repository after the Bun ruling. The required JS/TS gate is now `scripts/check-ts-allowlist.sh` (bash + awk); `scripts/check-ts-allowlist.deno.js` is RETAINED DELIBERATELY as a compatibility shim, not as residue. governance-reusable fetches `scripts/` at floating `ref: main` while consumers pin the workflow YAML, so deleting the shim breaks every consumer whose pinned YAML still invokes it — MEASURED 2026-09-04 at 269 repos. Owner ruled Deno REMOVED and Bun permanent (said three times, reaffirmed 2026-08-07). Must reach 0, but only via the three-phase retirement: shim (done, PR #730) -> repin consumers (task #59) -> delete. The single remaining probe hit is a COMMENT inside the shim, not a live invocation. Excludes */bindings/deno/, which is interop for OTHER people's Deno code and a separate question. - probe: git grep -lE "denoland/setup-deno|deno run|deno test|deno fmt|deno lint" -- ".github/workflows/*.yml" "scripts/*" | wc -l -- count: 0 -- ceiling: 4 +- count: 1 +- ceiling: 1 - severity: medium - policy: remediable - tri: substitute - accepted-until: 2026-11-01 ### deno-artefacts -- description: Files that exist only to serve Deno — the deno-ci workflow pair, the compiled check-ts-allowlist.deno.js, and the npm-to-deno migration template. Deleting these is the completion of the Bun migration, not a precondition of it. Excludes */bindings/deno/. +- description: Files that exist only to serve Deno. The npm-to-deno migration template was deleted in PR #730 (a live recipe pointing repos AT the retired runtime; its two inbound links were already dangling, naming MIGRATION.md after the .adoc rename). The one remaining artefact is the `check-ts-allowlist.deno.js` compatibility shim, which leaves when consumers have repinned — see deno-residue and task #59. Excludes */bindings/deno/. - probe: git ls-files ".github/workflows/deno*" "scripts/*deno*" "docs/migrations/npm-to-deno-template/*" | wc -l -- count: 2 -- ceiling: 2 +- count: 1 +- ceiling: 1 - severity: medium - policy: remediable - tri: substitute diff --git a/docs/JS-RUNTIME-POLICY.adoc b/docs/JS-RUNTIME-POLICY.adoc index ad49381a0..701790f00 100644 --- a/docs/JS-RUNTIME-POLICY.adoc +++ b/docs/JS-RUNTIME-POLICY.adoc @@ -11,8 +11,7 @@ runtimes and package management. It is referenced by `governance-reusable.yml` (enforcement) and the canonical template `.gitignore` files (rsr-template-repo, v3-templater). -See also: `scripts/purge-node-modules.sh` (remediation utility) and -`docs/migrations/npm-to-deno-template/MIGRATION.md` (per-repo recipe). +See also: `scripts/purge-node-modules.sh` (remediation utility). [NOTE] ==== diff --git a/docs/migrations/npm-to-deno-template/INVENTORY-2026-05-30.adoc b/docs/migrations/npm-to-deno-template/INVENTORY-2026-05-30.adoc deleted file mode 100644 index f18134e01..000000000 --- a/docs/migrations/npm-to-deno-template/INVENTORY-2026-05-30.adoc +++ /dev/null @@ -1,91 +0,0 @@ -== npm → Deno estate inventory — 2026-05-30 re-run - -Re-inventory per `+hyperpolymath/standards#262+` acceptance criterion. -The umbrella body (`+#253+`) cited *172* `+package.json+` manifests as -of 2026-05-28; a looser `+find+` on 2026-05-30 returned *437* before -excludes. - -Re-running with the umbrella’s documented exclude set produces *162* -manifests across *63* repositories — within 6 % of the planning -baseline, no STEP re-sizing required. - -=== Exclude set applied - -Parallel to -`+hypatia/lib/rules/cicd_rules.ex :nodejs_detected path_allow_prefixes+`: - -* `+**/node_modules/**+`, `+**/deps/**+` (vendored) -* `+rescript/+`, `+servers/+`, `+repos-monorepo/+`, `+linguist/+` -(upstream forks) -* `+hyperpolymath-archive/**+` (archived) -* `+**/vscode/**+` (VSCode extension host-required) -* `+affinescript-deno-test/+`, `+affinescript-cli/+` (bootstrap shims) -* `+**/example/**+`, `+**/examples/**+`, `+**/test-fixtures/**+`, -`+**/fixtures/**+` (fixtures) -* `+**/.git/**+` - -=== Per-repo manifest count (top 25) - -[width="100%",cols="50%,50%",options="header",] -|=== -|Manifests |Repo -|28 |developer-ecosystem - -|14 |ssg-collection - -|10 |affinescript - -|9 |accessibility-everywhere - -|7 |burble - -|7 |affinescript-stdlib-pr - -|5 |stapeln - -|5 |boj-server - -|4 |standards - -|4 |reposystem - -|4 |flat-mate - -|3 |wordpress-tools - -|3 |julia-the-viper - -|3 |idaptik - -|2 |zotero-tools, typed-wasm, proven, patallm-gallery, my-lang, -kaldor-iiot, claude-integrations -|=== - -=== STEP sizing (refreshed) - -[cols=",,,",options="header",] -|=== -|STEP |Tier |Repos |Manifests -|3 |≤2 manifest, smallest-first |~45 |~50 -|4 |3-7 manifest, mid |~13 |~57 -|5 |8+ manifest, larger |3 |27 -|6 |developer-ecosystem only |1 |28 -|7 |workspace finalisation |multi-repo wrap-up |— -|=== - -162 = 50 + 57 + 27 + 28. STEP-7 wrap-up captures any post-batch hygiene. - -=== Drift from umbrella - -[width="100%",cols="34%,33%,33%",options="header",] -|=== -|Source |Count |Note -|Umbrella `+#253+` (2026-05-28) |172 |Planning baseline -|Loose `+find+` (2026-05-30) |437 |Without excludes -|*This re-run (2026-05-30)* |*162* |Documented excludes; canonical -|=== - -Drift -10 (-5.8 %) vs umbrella. Within tolerance; no STEP re-ordering. - -Source TSV: `+~/Documents/npm-to-deno-inventory-2026-05-30.tsv+` -(`+\t+` per row, 162 rows). diff --git a/docs/migrations/npm-to-deno-template/MIGRATION.adoc b/docs/migrations/npm-to-deno-template/MIGRATION.adoc deleted file mode 100644 index d5d51dc05..000000000 --- a/docs/migrations/npm-to-deno-template/MIGRATION.adoc +++ /dev/null @@ -1,208 +0,0 @@ -== npm → Deno per-repo migration recipe - -Canonical procedure for migrating a hyperpolymath estate repository from -`+package.json+` + npm/Node to `+deno.json+` + Deno. - -Policy: `+docs/JS-RUNTIME-POLICY.adoc+` (Deno > Bun > pnpm > npm). -Campaign tracker: hyperpolymath/standards#253. Rule enforcement: hypatia -`+cicd_rules/nodejs_detected+` + `+npx_or_npm_run_in_ci+`. - -=== 0. Decide which class the repo is in - -Before touching anything, decide which of the migration classes applies. -Each class has a different end-state. - -[width="100%",cols="34%,33%,33%",options="header",] -|=== -|Class |Signal |End-state -|*A. Pure-Deno port* |Repo’s `+package.json+` only lists dev-only -Node-compatible tools (`+typescript+`, `+vitest+`, build helpers). No -host contract requires Node. |Delete `+package.json+` + -`+package-lock.json+`. Author `+deno.json+`. CI workflows swap to -`+deno test+`/`+deno task+`. - -|*B. npm wrapper via Deno* |Repo wraps an npm-published tool that does -not yet have a Deno-native fork (e.g., `+rescript+`, `+vite+`, -`+tailwindcss+`). |Keep dependency expressed as `+npm:pkg@semver+` -inside `+deno.json+`’s `+imports+`. Tasks call -`+deno run -A --node-modules-dir=auto npm:pkg+`. No `+package.json+`. - -|*C. Carve-out* |One of the six classes in -`+cicd_rules/nodejs_detected+` `+path_allow_prefixes+` (VSCode -extension, bootstrap shim, upstream fork, archived, vendored, -example/fixture). |*Skip migration.* File stays on npm; no PR. -|=== - -A given repo with multiple `+package.json+` files can split across -classes — handle each manifest on its own merit. - -=== 1. Inventory the current `+package.json+` - -[source,bash] ----- -# Capture starting point. -cat package.json -ls -la package-lock.json bun.lockb yarn.lock pnpm-lock.yaml 2>/dev/null ----- - -Record: - -* Direct deps (`+dependencies+` + `+devDependencies+`). -* Scripts (`+scripts.*+`). -* `+engines.node+`, `+engines.npm+` — note for replacement by -`+engines.deno+`. -* `+private+`, `+type+`, `+exports+` — preserved as needed. - -=== 2. Author `+deno.json+` from the canonical template - -Copy `+deno.json+` from this directory. Adjust: - -* `+name+` — `+@hyperpolymath/+`. -* `+version+` — preserve from `+package.json+`. -* `+license+` — `+MPL-2.0-or-later+` (estate default) unless repo policy -differs. -* `+compilerOptions+` — preserve `+strict+` and friends from -`+tsconfig.json+` if present. -* `+imports+` — populate from `+dependencies+`: -** Deno-native: `+"@std/": "https://deno.land/std@0.224.0/"+` (and -similar). -** JSR: `+"@scope/pkg": "jsr:@scope/pkg@^1.2.3"+`. -** npm fallback: `+"pkg": "npm:pkg@^1.2.3"+` (Class B only). -* `+tasks+` — port from `+scripts+`: -** `+"build": "rescript"+` → -`+"build": "deno run -A --node-modules-dir=auto npm:rescript"+`. -** `+"test": "vitest"+` → `+"test": "deno test -A src/"+` (port tests to -Deno test API where reachable; if not yet portable, -`+"test": "deno run -A --node-modules-dir=auto npm:vitest"+`). -* `+nodeModulesDir+`: -** Default `+"none"+` (Class A — pure Deno). -** Set to `+"auto"+` only when an npm package’s lifecycle requires it -(Class B; rescript and most ESM-shipped npm packages are fine without -it). - -=== 3. Delete the npm scaffolding - -[source,bash] ----- -git rm package.json package-lock.json -# Also remove bun.lockb / yarn.lock / pnpm-lock.yaml / .npmrc if present. -git rm -f bun.lockb yarn.lock pnpm-lock.yaml .npmrc 2>/dev/null || true - -# node_modules/ should already be in .gitignore. -rm -rf node_modules ----- - -=== 4. Update `+.gitignore+` - -Confirm these entries are present (RSR canonical template propagates -them — see -`+docs/JS-RUNTIME-POLICY.adoc §Canonical .gitignore Entries+`): - -.... -# npm-avoidant (standards#67): estate JS-runtime policy is Bun>Deno>pnpm>npm. -package-lock.json -**/package-lock.json -node_modules/ -**/node_modules/ -bun.lockb -yarn.lock -pnpm-lock.yaml -.... - -=== 5. Migrate CI workflows - -Search for any of these and replace: - -[width="100%",cols="50%,50%",options="header",] -|=== -|Before |After -|`+actions/setup-node@+` |`+denoland/setup-deno@+` (or remove -if no JS step remains) - -|`+npm ci+` / `+npm install+` |`+deno cache +` (often -unnecessary — Deno caches at first run) - -|`+npm test+` / `+npm run test+` |`+deno task test+` (or -`+deno test -A src/+`) - -|`+npx +` |`+deno run -A --node-modules-dir=auto npm:+` -(Class B) or Deno-native equivalent (Class A) -|=== - -Note: hypatia `+cicd_rules/npx_or_npm_run_in_ci+` blocks `+npx+` and -`+npm run+` in CI run-blocks (added 2026-05-28). Don’t leave any. - -=== 6. Verify locally - -[source,bash] ----- -deno check src/ -deno lint src/ -deno fmt --check src/ -deno test -A src/ ----- - -Class B (npm wrapper) — exercise the wrapped tool end-to-end: - -[source,bash] ----- -deno task build -deno task test ----- - -=== 7. Commit pattern - -[source,bash] ----- -git add deno.json .gitignore .github/workflows/ -git rm package.json package-lock.json -git commit -m "feat(deno): migrate npm → Deno (standards#253) - - - -Class: A | B (per docs/migrations/npm-to-deno-template/MIGRATION.md) -Carry-forward: \">" ----- - -=== 8. PR + auto-merge - -Per estate convention: auto-merge with squash. - -[source,bash] ----- -gh pr create --title "feat(deno): npm → Deno (standards#253)" \ - --body "" -gh pr merge --auto --squash --delete-branch ----- - -=== Carry-forward patterns observed in oikos Phase 5 + 5 follow-ups (memory) - -* *ReScript wrapping* (canonical Class B): -`+deno run -A --node-modules-dir=auto npm:rescript@^12.0.0+`. -`+--allow-scripts=npm:rescript+` when the install lifecycle requires it. -* *Tailwind / vite / esbuild* — same pattern as rescript: -`+npm:@+`, `+--node-modules-dir=auto+`. -* *`+type: "module"+` repos* — Deno is ESM-native, no extra step. -* *`+exports+` field* — preserve in `+deno.json+` if the package is -published; otherwise drop. - -=== Anti-patterns - -* ❌ Don’t keep `+package.json+` "`for tooling only`" — `+deno.json+` -covers fmt/lint/test/tasks. -* ❌ Don’t fall back to `+npm:+` specifiers when a JSR or Deno-native -equivalent exists (use `+deno info +` to check). -* ❌ Don’t commit `+node_modules/+` even on Class B — -`+--node-modules-dir=auto+` regenerates at run-time. -* ❌ Don’t add `+"engines": {"node": "..."}+` to `+deno.json+` — Deno -doesn’t honour it and it signals the repo isn’t fully migrated. - -=== When migration is blocked - -If a `+package.json+` cannot be removed (host-required, Node-only -library, npm publish target), the path goes in the hypatia rule’s -`+path_allow_prefixes+` instead. See -`+standards/.claude/CLAUDE.md §npm Exemptions (Approved)+` for the -canonical exemption table. diff --git a/scripts/check-ts-allowlist.affine b/scripts/check-ts-allowlist.affine new file mode 100644 index 000000000..7d53ef033 --- /dev/null +++ b/scripts/check-ts-allowlist.affine @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MPL-2.0 +// Ported via Harvard Engine (Semantic pass) + +module check-ts-allowlist; + +// SPDX-License-Identifier: MPL-2.0 +// 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 +// 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 unknown +// 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 unknown non-exempt files remain; +// otherwise print the success line. +// +// Permission scope is `--allow-read` only. No network, no env, no write. + +let DIR_NAMES_ALLOWED = new Set([ + "bindings", "tests", "test", "scripts", + "mcp-adapter", "cli", "vendor", "examples", "ffi", + "node_modules", "benchmarks", +]); + +fn builtinAllowed(p: string): boolean { + if (p.endsWith(".d.ts")) return true; + let 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; + let segs = p.split("/"); + for (let i = 0; i < segs.length - 1; i++) { + let 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; +} + +fn globToRegex(g: string): RegExp { + // The Python implementation stripped a leading "./" via `.lstrip('./')` + // which is a multi-char strip (unknown 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 = ""; + let 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 + "$"); +} + +struct Exemption { raw: string; rx: RegExp; } + +fn normalizeRepoPath(p: string): string { + let out = p.trim(); + while (out.length > 0 && (out[0] === "." || out[0] === "/")) { + out = out.slice(1); + } + return out; +} + +fn normalizeExemptionCell(cell: string): string { + let out = cell.trim(); + let codeSpan = out.match(/^`([^`]+)`$/) ?? out.match(/^`([^`]+)`/); + if (codeSpan) { + out = codeSpan[1].trim(); + } + return normalizeRepoPath(out); +} + +fn nonExemptionCell(cell: string): boolean { + return cell === "" || /^:?-{3,}:?$/.test(cell) || /^path\b/i.test(cell); +} + +async fn loadExemptionsFromClaudeMd(): Exemption[] { + // Layer 2 — heading-table exemptions parsed from `.claude/CLAUDE.md`. + // + // Heading regex relaxation (was: literal `TypeScript [Ee]xemptions`): + // now matches unknown markdown heading containing the substring sequence + // (TypeScript|JavaScript|TS|JS|.tsx?) … Exemption(s). Picks up + // `### TypeScript / JavaScript Exemptions (Approved)` (the + // affinescript form), the singular `### TypeScript Exemption`, and + // `.ts` / `.tsx`-mentioning variants. Anchored to a markdown heading + // prefix so prose mentions of the phrase elsewhere in the file do + // NOT trigger table parsing. + // + // Multi-table support: scans every heading; on hitting unknown heading + // that's NOT an exemption-section heading we leave table-mode (the + // original "break on first heading" was correct for the heredoc but + // a multi-section file would miss the second exemption table). + const exemptions: Exemption[] = []; + let text: string; + try { + text = await Deno.readTextFile(".claude/CLAUDE.md"); + } catch { + return exemptions; + } + let tsHeading = + /^#{1,4}\s+.*(?:TypeScript|JavaScript|TS|JS|\.tsx?)\b[^#\n]*[Ee]xemption/; + let anyHeading = /^#{1,4}\s/; + let inTable = false; + for (const line of text.split("\n")) { + if (tsHeading.test(line)) { + inTable = true; + continue; + } + if (inTable && anyHeading.test(line)) { + // A different heading — leave table mode but keep scanning for + // another exemption section in the same file. + inTable = false; + continue; + } + let tableLine = line.trim(); + if (inTable && tableLine.startsWith("|")) { + let cells = tableLine.split("|"); + if (cells.length >= 3) { + let raw = normalizeExemptionCell(cells[1]); + if (!nonExemptionCell(raw)) { + exemptions.push({ raw, rx: globToRegex(raw) }); + } + } + } + } + return exemptions; +} + +async fn loadExemptionsFromAllowlistFile(): Exemption[] { + // Layer 2.5 — optional plain-text allowlist at the repo root. + // One glob per line. Lines starting with `#` are comments; blank + // lines are ignored. Decouples gate-pass from documentation prose + // (the CLAUDE.md heading-table is the documented variant; this + // file is the typed-infrastructure variant). Both sources merge + // additively — either alone is sufficient. + const exemptions: Exemption[] = []; + let text: string; + try { + text = await Deno.readTextFile(".governance-allowlist"); + } catch { + return exemptions; + } + for (const rawLine of text.split("\n")) { + let line = normalizeExemptionCell(rawLine); + if (line === "" || line.startsWith("#")) continue; + exemptions.push({ raw: line, rx: globToRegex(line) }); + } + return exemptions; +} + +async fn loadExemptions(): Exemption[] { + let fromCm = await loadExemptionsFromClaudeMd(); + let fromAllow = await loadExemptionsFromAllowlistFile(); + return [...fromCm, ...fromAllow]; +} + +fn exempt(p: string, exemptions: Exemption[]): boolean { + let target = normalizeRepoPath(p); + for (const e of exemptions) { + if (e.rx.test(target)) return true; + let bare = normalizeRepoPath(e.raw); + if (target === bare) return true; + if (bare.endsWith("/") && target.startsWith(bare)) return true; + } + return false; +} + +fn 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)) { + let name = entry.name; + // Skip dotfiles/dotted dirs (matching Python's check on path parts). + if (name.startsWith(".") && name !== "." && name !== "..") continue; + let full = dir === "." ? name : `${dir}/${name}`; + if (entry.isDirectory) { + yield* walkTs(full); + } else if (entry.isFile) { + if (isTypeScriptArtifact(name)) { + yield full; + } + } + } +} + +async fn main() { + let exemptions = await loadExemptions(); + const found: string[] = []; + for await (const f of walkTs(".")) { + found.push(f); + } + let 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 a 'TypeScript Exemptions' table in .claude/CLAUDE.md (Layer 2)"); + console.log(" (d) add a line to .governance-allowlist at the repo root (Layer 2.5 — typed infrastructure file)"); + console.log(""); + console.log("See docs/EXEMPTION-MECHANISMS.adoc for the full mechanism reference."); + if (exemptions.length > 0) { + console.log(`\n(Currently ${exemptions.length} exemption(s) parsed across both layers.)`); + } + Deno.exit(1); + } + console.log(`✅ No TypeScript files outside allowlist (${exemptions.length} per-repo exemption(s) parsed across CLAUDE.md + .governance-allowlist).`); +} + +if (import.meta.main) { + await main(); +} + diff --git a/scripts/check-ts-allowlist.deno.js b/scripts/check-ts-allowlist.deno.js new file mode 100644 index 000000000..b1aa6fa2f --- /dev/null +++ b/scripts/check-ts-allowlist.deno.js @@ -0,0 +1,360 @@ +// 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" }; +const Ok = (value) => ({ tag: "Ok", value }); +const Err = (error) => ({ tag: "Err", error }); +const Unit = null; +const print = (s) => { Deno.stdout.writeSync(new TextEncoder().encode(String(s))); }; +const println = (s) => { console.log(String(s)); }; +// ---- Deno host shims (extern fn lowering targets, issue #122) ---- +// Kept tiny + inlined so emitted modules are genuinely drop-in (no extra +// package to publish or resolve). The same surface is mirrored, for +// standalone `deno test`, by packages/affine-deno/mod.js. +const __as_ensureDir = (p) => { + try { Deno.mkdirSync(p, { recursive: true }); } + catch (e) { if (!(e instanceof Deno.errors.AlreadyExists)) throw e; } +}; +const __as_pathJoin = (a, b) => { + if (a.length === 0) return b; + const sep = a.endsWith("/") || a.endsWith("\\") ? "" : "/"; + return a + sep + b; +}; +const __as_readDirNames = (p) => { + const names = []; + for (const entry of Deno.readDirSync(p)) { + if (entry.isFile) names.push(entry.name); + } + return names; +}; +const __as_isNotFound = (e) => (e instanceof Deno.errors.NotFound); +const __as_walkRecursive = (root) => { + const out = []; + const rec = (dir) => { + for (const entry of Deno.readDirSync(dir)) { + const full = (dir.endsWith("/") ? dir : dir + "/") + entry.name; + if (entry.isFile) out.push(full); + else if (entry.isDirectory) rec(full); + } + }; + rec(root); + return out; +}; +const __as_regexMatch = (s, pat) => new RegExp(pat).test(String(s)); +const __as_wasmInstance = (bytes) => + new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; +const __as_wasmCall = (exports, name, args) => Number(exports[name](...(args || []))); +// ---- motion (bindings #4): consumer-provided import ---- +// Host JS environment must expose globalThis.__as_motion (the motion +// library or a compatible mock). Tests set it in the harness before +// importing the generated module; production consumers typically do +// `import * as m from "motion"; globalThis.__as_motion = m;` once at +// module-init time. The AffineScript-side externs (stdlib/Motion.affine) +// don't see this indirection — they call __as_motion* helpers directly. +const __as_motionAnimate = (target, keyframes, options) => + globalThis.__as_motion.animate(target, keyframes, options); +const __as_motionAwait = (controls) => + Promise.resolve(controls).then(() => 0); +const __as_motionCancel = (controls) => { + if (controls && typeof controls.cancel === "function") controls.cancel(); + return 0; +}; +// `animateMini` / `tween` / `spring` / `ease` — bindings #4 follow-up +// surface. Each helper resolves the host method on globalThis.__as_motion +// at call time so a mock that only stubs a subset still works for the +// rest (the smoke harness exercises every variant). +const __as_motionAnimateMini = (target, keyframes, options) => + globalThis.__as_motion.animateMini(target, keyframes, options); +const __as_motionTween = (target, from, to, options) => + globalThis.__as_motion.tween(target, from, to, options); +const __as_motionSpring = (target, keyframes, springConfig) => + globalThis.__as_motion.spring(target, keyframes, springConfig); +const __as_motionEase = (name) => + globalThis.__as_motion.ease(name); +// ---- pixi.js (bindings #1): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi (the PIXI namespace +// from `import * as PIXI from "pixi.js"`). Tests set it in the harness +// before importing the generated module. +const __as_pixiAppInit = async (options) => { + const app = new globalThis.__as_pixi.Application(); + await app.init(options); + return app; +}; +const __as_pixiAppCanvas = (app) => app.canvas; +const __as_pixiAppStage = (app) => app.stage; +const __as_pixiAppTicker = (app) => app.ticker; +const __as_pixiAppDestroy = (app) => { app.destroy(); return 0; }; +const __as_pixiContainerNew = () => new globalThis.__as_pixi.Container(); +const __as_pixiContainerAddChild = (p, c) => { p.addChild(c); return 0; }; +const __as_pixiContainerRemoveChild = (p, c) => { p.removeChild(c); return 0; }; +const __as_pixiContainerSetPosition = (c, x, y) => { c.x = x; c.y = y; return 0; }; +const __as_pixiContainerSetVisible = (c, v) => { c.visible = v; return 0; }; +const __as_pixiContainerDestroy = (c) => { c.destroy(); return 0; }; +const __as_pixiSpriteFrom = (t) => new globalThis.__as_pixi.Sprite(t); +// Upcasts are identity — PIXI's class hierarchy makes Sprite/Graphics/ +// Text actual Container subclasses, so the JS object is the same. +const __as_pixiSpriteAsContainer = (s) => s; +const __as_pixiTextureFromUrl = (url) => globalThis.__as_pixi.Texture.from(url); +const __as_pixiGraphicsNew = () => new globalThis.__as_pixi.Graphics(); +const __as_pixiGraphicsRect = (g, x, y, w, h) => { g.rect(x, y, w, h); return 0; }; +const __as_pixiGraphicsFill = (g, color) => { g.fill({ color }); return 0; }; +const __as_pixiGraphicsClear = (g) => { g.clear(); return 0; }; +const __as_pixiGraphicsAsContainer = (g) => g; +const __as_pixiTextNew = (options) => new globalThis.__as_pixi.Text(options); +const __as_pixiTextSetText = (t, content) => { t.text = content; return 0; }; +const __as_pixiTextAsContainer = (t) => t; +const __as_pixiTickerAdd = (t, cb) => { t.add(cb); return 0; }; +const __as_pixiTickerStart = (t) => { t.start(); return 0; }; +const __as_pixiTickerStop = (t) => { t.stop(); return 0; }; +// ---- @pixi/ui (bindings #3): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi_ui (the namespace +// from `import * as PixiUI from "@pixi/ui"`). Tests set it in the +// harness before importing the generated module; production +// consumers typically do once at module-init time. The +// AffineScript-side externs (stdlib/PixiUI.affine) don't see this +// indirection — they call __as_pixiUi* helpers directly. +// +// Upcasts to Container are identity — @pixi/ui's Button / +// FancyButton / Slider / Switch are all real PIXI.Container +// subclasses, so the JS object is the same. +const __as_pixiUiButtonNew = (options) => new globalThis.__as_pixi_ui.Button(options); +const __as_pixiUiButtonOnPress = (b, cb) => { b.onPress.connect(cb); return 0; }; +const __as_pixiUiButtonAsContainer = (b) => b; +const __as_pixiUiFancyButtonNew = (options) => new globalThis.__as_pixi_ui.FancyButton(options); +const __as_pixiUiFancyButtonAsContainer = (b) => b; +const __as_pixiUiSliderNew = (options) => new globalThis.__as_pixi_ui.Slider(options); +const __as_pixiUiSliderOnUpdate = (s, cb) => { s.onUpdate.connect(cb); return 0; }; +const __as_pixiUiSliderAsContainer = (s) => s; +const __as_pixiUiSwitchNew = (options) => new globalThis.__as_pixi_ui.Switch(options); +const __as_pixiUiSwitchOnChange = (sw, cb) => { sw.onChange.connect(cb); return 0; }; +const __as_pixiUiSwitchAsContainer = (sw) => sw; +// ---- @pixi/sound (bindings #2): consumer-provided import ---- +// Host JS environment exposes globalThis.__as_pixi_sound (the `Sound` +// named export from `@pixi/sound`). Tests set it in the harness before +// importing the generated module; production consumers typically do +// `import { Sound } from "@pixi/sound"; globalThis.__as_pixi_sound = Sound;` +// once at module-init time. The AffineScript-side externs +// (stdlib/PixiSound.affine) don't see this indirection — they call +// __as_pixiSound* helpers directly. +const __as_pixiSoundFrom = (url) => globalThis.__as_pixi_sound.from(url); +const __as_pixiSoundPlay = (s) => { s.play(); return 0; }; +const __as_pixiSoundStop = (s) => { s.stop(); return 0; }; +const __as_pixiSoundPause = (s) => { s.pause(); return 0; }; +const __as_pixiSoundResume = (s) => { s.resume(); return 0; }; +const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; }; +const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; }; +// `++` is overloaded (string concat / array concat); `a + b` would +// stringify arrays. Dispatch on shape so stdlib/string.affine's +// `result ++ [x]` and `a ++ b` are both correct. +const __as_concat = (a, b) => Array.isArray(a) ? a.concat(b) : (a + b); +// Honest host/runtime primitives underpinning the AffineScript-level +// stdlib/string.affine (its is_empty/starts_with/ends_with/split/join/ +// replace/... are real AffineScript on top of these). +const __as_strSub = (s, start, n) => String(s).slice(start, start + n); +const __as_strGet = (s, i) => String(s)[i]; +const __as_strFind = (s, n) => String(s).indexOf(n); +const __as_charToInt = (c) => String(c).codePointAt(0); +const __as_intToChar = (n) => String.fromCodePoint(n); +const __as_parseInt = (s) => { + const n = parseInt(String(s), 10); + return Number.isNaN(n) ? None : Some(n); +}; +const __as_parseFloat = (s) => { + const n = parseFloat(String(s)); + return Number.isNaN(n) ? None : Some(n); +}; +const __as_show = (v) => (typeof v === "string" ? v : JSON.stringify(v)); +// ---- Http (issue #160): portable fetch round-trip ---- +// `headers` crosses the boundary as an AffineScript [(String, String)] +// assoc list == JS array of [name, value] pairs. `body` is an +// AffineScript Option == { tag: "Some", value } | { tag: "None" }. +// The result is the `Response` record shape { status, headers, body }. +const __as_httpHeadersToObject = (pairs) => { + const o = {}; + for (const kv of (pairs || [])) o[kv[0]] = kv[1]; + return o; +}; +const __as_httpHeadersFromResponse = (res) => { + const out = []; + res.headers.forEach((value, key) => out.push([key, value])); + return out; +}; +// ---- hpm-json-rsr Zig FFI shims (stdlib/json.affine v0.3) ---- +// `HpmJsonValue` is opaque to AffineScript; on Deno-ESM it's just the +// underlying JS value from JSON.parse. The shims mirror the sentinel +// conventions of the Zig exports so the AffineScript-side wrappers +// (`to_json`, `parse`) behave identically across backends. +const __as_hpmJsonParse = (s) => { + try { return Some(JSON.parse(String(s))); } catch (_e) { return None; } +}; +const __as_hpmJsonFree = (_v) => 0; +const __as_hpmJsonType = (v) => { + if (v === null || v === undefined) return 0; + if (typeof v === "boolean") return 1; + if (typeof v === "number") return Number.isInteger(v) ? 2 : 3; + if (typeof v === "string") return 4; + if (Array.isArray(v)) return 5; + if (typeof v === "object") return 6; + return -1; +}; +const __as_hpmJsonBool = (v) => (typeof v === "boolean" ? (v ? 1 : 0) : -1); +const __as_hpmJsonInt = (v) => + (typeof v === "number" ? Math.trunc(v) : Number.MIN_SAFE_INTEGER); +const __as_hpmJsonFloat = (v) => (typeof v === "number" ? v : NaN); +const __as_hpmJsonString = (v) => (typeof v === "string" ? v : ""); +const __as_hpmJsonObjectGet = (v, k) => { + if (v === null || typeof v !== "object" || Array.isArray(v)) return None; + return Object.prototype.hasOwnProperty.call(v, String(k)) + ? Some(v[String(k)]) : None; +}; +const __as_hpmJsonArrayLen = (v) => (Array.isArray(v) ? v.length : 0); +const __as_hpmJsonArrayGet = (v, i) => { + if (!Array.isArray(v)) return None; + const idx = Number(i); + return (idx >= 0 && idx < v.length) ? Some(v[idx]) : None; +}; +const __as_hpmJsonEscapeString = (s) => { + let out = ""; + const src = String(s); + for (let i = 0; i < src.length; i++) { + const c = src.charCodeAt(i); + if (c === 0x22) out += "\\\""; + else if (c === 0x5c) out += "\\\\"; + else if (c === 0x0a) out += "\\n"; + else if (c === 0x0d) out += "\\r"; + else if (c === 0x09) out += "\\t"; + else if (c === 0x08) out += "\\b"; + else if (c === 0x0c) out += "\\f"; + else if (c < 0x20) out += "\\u00" + c.toString(16).padStart(2, "0"); + else out += src[i]; + } + return out; +}; +const __as_httpFetch = async (url, method, headers, bodyOpt) => { + const init = { method, headers: __as_httpHeadersToObject(headers) }; + if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value; + // `globalThis.fetch` explicitly: the stdlib `Http.fetch` compiles to a + // module-level `function fetch`, which would otherwise shadow the host. + const res = await globalThis.fetch(url, init); + const text = await res.text(); + return { + status: res.status, + headers: __as_httpHeadersFromResponse(res), + body: text, + }; +}; +// ---- end runtime ---- + +export function split(s, delimiter) { + const slen = ((s).length); + const dlen = ((delimiter).length); + if ((dlen === 0)) { let result = []; let i = 0; while ((i < slen)) { result = __as_concat(result, [__as_strSub(s, i, 1)]); i = (i + 1); } return result; } + let result = []; + let current_start = 0; + let i = 0; + while ((i <= (slen - dlen))) { if ((__as_strSub(s, i, dlen) === delimiter)) { result = __as_concat(result, [__as_strSub(s, current_start, (i - current_start))]); current_start = (i + dlen); i = (i + dlen); } else { i = (i + 1); } } + result = __as_concat(result, [__as_strSub(s, current_start, (slen - current_start))]); + return result; +} + +function ends_with(s, suffix) { + const slen = ((s).length); + const sfxlen = ((suffix).length); + return ((sfxlen > slen) ? (() => { return false; })() : (() => { return (__as_strSub(s, (slen - sfxlen), sfxlen) === suffix); })()); +} + +const DIR_NAMES_ALLOWED = ["bindings", "tests", "test", "scripts", "mcp-adapter", "cli", "vendor", "examples", "ffi", "node_modules", "benchmarks"]; +function builtinAllowed(p) { + if (ends_with(p, ".d.ts")) { return true; } + const segs = split(p, "/"); + const segs_len = ((segs).length); + const base = segs[(segs_len - 1)]; + if ((base === "mod.ts")) { return true; } + if (((((base === "lsp-server.ts") || (base === "lsp_server.ts")) || (base === "lsp.ts")) || ends_with(base, "-lsp.ts"))) { return true; } + if ((ends_with(base, ".bench.ts") || ends_with(base, "_bench.ts"))) { return true; } + let i = 0; + while ((i < (segs_len - 1))) { const s = segs[i]; let j = 0; const dn_len = ((DIR_NAMES_ALLOWED).length); while ((j < dn_len)) { if ((s === DIR_NAMES_ALLOWED[j])) { return true; } j = (j + 1); } if (__as_regexMatch(s, "vscode")) { return true; } if (__as_regexMatch(s, "^deno-")) { return true; } i = (i + 1); } + return false; +} + +function globToRegex(g) { + let g2 = g; + while (((((g2).length) > 0) && ((__as_strSub(g2, 0, 1) === ".") || (__as_strSub(g2, 0, 1) === "/")))) { g2 = __as_strSub(g2, 1, (((g2).length) - 1)); } + let out = ""; + let i = 0; + const g2_len = ((g2).length); + while ((i < g2_len)) { const c = __as_strSub(g2, i, 1); if ((c === "*")) { out = __as_concat(out, ".*"); } else { if ((c === "?")) { out = __as_concat(out, "."); } else { if (((((((((((((c === ".") || (c === "+")) || (c === "(")) || (c === ")")) || (c === "{")) || (c === "}")) || (c === "[")) || (c === "]")) || (c === "|")) || (c === "^")) || (c === "$")) || (c === "\\"))) { out = __as_concat(__as_concat(out, "\\"), c); } else { out = __as_concat(out, c); } } } i = (i + 1); } + return __as_concat(__as_concat("^", out), "$"); +} + +// 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 ""; } })(); + if ((text === "")) { return exemptions; } + const tsHeading = "^#{1,4}\\s+.*(?:TypeScript|JavaScript|TS|JS|\\.tsx?)\\b[^#\\n]*[Ee]xemption"; + const anyHeading = "^#{1,4}\\s"; + let inTable = false; + 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))) { 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; +} + +function loadExemptionsFromAllowlistFile() { + let exemptions = []; + const text = (() => { try { return (() => { return Deno.readTextFileSync(".governance-allowlist"); })(); } catch (__e) { return ""; } })(); + if ((text === "")) { return exemptions; } + const lines = split(text, "\n"); + let i = 0; + const lines_len = ((lines).length); + 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; +} + +function loadExemptions() { + return __as_concat(loadExemptionsFromClaudeMd(), loadExemptionsFromAllowlistFile()); +} + +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(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; +} + +export function main() { + const exemptions = loadExemptions(); + let found = []; + 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 (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); + while ((k < found_len)) { const f = found[k]; if (((!builtinAllowed(f)) && (!isExempt(f, exemptions)))) { bad = __as_concat(bad, [f]); } k = (k + 1); } + if ((((bad).length) > 0)) { (console.error("\u274C TypeScript files detected outside the allowlist.\n"), 0); let m = 0; const bad_len = ((bad).length); while ((m < bad_len)) { const f = bad[m]; (console.error(__as_concat(" ", f)), 0); m = (m + 1); } (console.error(""), 0); (console.error("To resolve, choose one:"), 0); (console.error(" (a) migrate the file to AffineScript"), 0); (console.error(" (b) move to an allowlisted bridge path"), 0); (console.error(" (c) add an entry to a 'TypeScript Exemptions' table in .claude/CLAUDE.md (Layer 2)"), 0); (console.error(" (d) add a line to .governance-allowlist at the repo root (Layer 2.5 \u2014 typed infrastructure file)"), 0); (console.error(""), 0); (console.error("See docs/EXEMPTION-MECHANISMS.adoc for the full mechanism reference."), 0); if ((((exemptions).length) > 0)) { (console.error(__as_concat(__as_concat("\n(Currently ", String(((exemptions).length))), " exemption(s) parsed across both layers.)")), 0); } return Deno.exit(1); } + println(__as_concat(__as_concat("\u2705 No TypeScript files outside allowlist (", String(((exemptions).length))), " per-repo exemption(s) parsed across CLAUDE.md + .governance-allowlist).")); + return 0; +} + +await main();