diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 8e5e12c..f0c04aa 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 # Full history for better pattern analysis - name: Setup Elixir for Hypatia scanner - uses: erlef/setup-beam@ee09b1e59bb240681c382eb1f0abc6a04af72764 # v1.18.2 + uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.18.2 with: elixir-version: '1.19.4' otp-version: '28.3' diff --git a/.github/workflows/rsr-antipattern.yml b/.github/workflows/rsr-antipattern.yml index e81eafa..ff16d3a 100644 --- a/.github/workflows/rsr-antipattern.yml +++ b/.github/workflows/rsr-antipattern.yml @@ -27,15 +27,116 @@ jobs: - name: Check for TypeScript run: | - # Exclude bindings/deno/ - those are Deno FFI files using Deno.dlopen, not plain TypeScript - # Exclude .d.ts files - those are TypeScript type declarations for ReScript FFI - TS_FILES=$(find . \( -name "*.ts" -o -name "*.tsx" \) | grep -v node_modules | grep -v 'bindings/deno' | grep -v '\.d\.ts$' || true) - if [ -n "$TS_FILES" ]; then - echo "❌ TypeScript files detected - use ReScript instead" - echo "$TS_FILES" - exit 1 - fi - echo "✅ No TypeScript files (Deno FFI bindings excluded)" + python3 << 'PYEOF' + import re, sys, pathlib + + # Universal allowlist — bridges and conventions that need no per-repo declaration. + # Implemented as explicit string predicates rather than glob patterns so that + # top-level directories (e.g. tests/foo.ts) are matched the same as nested ones, + # which fnmatch's * cannot do reliably. + DIR_NAMES_ALLOWED = { + 'bindings', 'tests', 'test', 'scripts', + 'mcp-adapter', 'cli', 'vendor', 'examples', 'ffi', + 'node_modules', 'benchmarks', + } + + def builtin_allowed(p): + # `p` is a posix-style path with no leading ./ + # 1. Type declaration files + if p.endswith('.d.ts'): + return True + # 2. Canonical Deno entrypoint filenames + base = p.rsplit('/', 1)[-1] + if base == 'mod.ts': + return True + # 3. LSP server files (filename suffixes) + if base in ('lsp-server.ts', 'lsp_server.ts', 'lsp.ts') or base.endswith('-lsp.ts'): + return True + # 4. Benchmark files (filename suffixes) + if base.endswith('.bench.ts') or base.endswith('_bench.ts'): + return True + # 5. Any directory segment (excluding basename) matches an allowed dir + segs = p.split('/') + for s in segs[:-1]: + if s in DIR_NAMES_ALLOWED: + return True + # vscode-anything or anything-vscode + if 'vscode' in s: + return True + # deno-named subprojects + if s.startswith('deno-'): + return True + return False + + # Per-repo exemptions parsed from .claude/CLAUDE.md "TypeScript Exemptions" table. + # This is the documented single source of truth: adding one row here unblocks CI. + # Glob characters: '*' and '**' both mean "any chars including /". This loose + # interpretation matches user intent when an exemption row reads, e.g., + # `affinescript-deno-test/*.ts` (covering nested files too). + def glob_to_regex(g): + out = [] + for c in g.lstrip('./'): + if c == '*': out.append('.*') + elif c == '?': out.append('.') + elif c in '.+(){}[]|^$\\': out.append(re.escape(c)) + else: out.append(c) + return re.compile('^' + ''.join(out) + '$') + + exemption_patterns = [] + claude_md = pathlib.Path('.claude/CLAUDE.md') + if claude_md.exists(): + in_table = False + for line in claude_md.read_text(encoding='utf-8').splitlines(): + if re.search(r'TypeScript [Ee]xemptions', line): + in_table = True + continue + if in_table and line.startswith(('### ', '## ', '# ')): + break + if in_table and line.startswith('|'): + m = re.match(r'\|\s*`([^`]+)`', line) + if m: + exemption_patterns.append((m.group(1), glob_to_regex(m.group(1)))) + + def exempt(p): + for raw, regex in exemption_patterns: + if regex.match(p): + return True + # Also allow exact-path matches and prefix matches for paths + # ending in `/` + if p == raw.lstrip('./'): + return True + if raw.endswith('/') and p.startswith(raw.lstrip('./')): + return True + return False + + # Find all .ts and .tsx files (excluding common dot-dirs that find normally skips) + found = [] + for ext in ('ts', 'tsx'): + for p in pathlib.Path('.').rglob(f'*.{ext}'): + parts = p.parts + if any(part.startswith('.') and part not in ('.', '..') for part in parts): + continue + found.append(p.as_posix().lstrip('./')) + + bad = sorted(f for f in found if not (builtin_allowed(f) or exempt(f))) + if bad: + print("❌ TypeScript files detected outside the allowlist.\n") + for f in bad: + print(f" {f}") + print() + print("To resolve, choose one:") + print(" (a) migrate the file to AffineScript") + print(" (see Human_Programming_Guide.adoc 'Migrating from -script Languages')") + print(" (b) move to an allowlisted bridge path") + print(" (bindings/, tests/, test/, scripts/, benchmarks/, mcp-adapter/,") + print(" *vscode*/, cli/, deno-*/, vendor/, examples/, ffi/)") + print(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md") + print(" with rationale + unblock condition") + if exemption_patterns: + print(f"\n(Currently {len(exemption_patterns)} exemption(s) parsed from .claude/CLAUDE.md.)") + sys.exit(1) + print(f"✅ No TypeScript files outside allowlist ({len(exemption_patterns)} per-repo exemption(s) parsed).") + PYEOF - name: Check for Go run: |