Skip to content

Commit 64ca0d1

Browse files
ci(antipattern): fix top-level dir + benchmark/lsp filename matching (#20)
Fix-up for v3 (which used fnmatch globs that don't match top-level directories like `tests/foo.ts`). Replaces fnmatch-based glob matching with explicit string predicates: - Allowed directory names match at any path depth (incl. top-level) - Filename suffixes for LSP servers (`lsp-server.ts` etc.) and benchmarks (`*_bench.ts`, `*.bench.ts`) - Per-repo .claude/CLAUDE.md exemption table parsed; `*` and `**` both treated as 'any chars including /' (loose user-friendly interpretation). Verified against the affinescript exemption table + ubicity tests/+benchmarks/ + various edge cases.
1 parent 3c5dbb0 commit 64ca0d1

1 file changed

Lines changed: 109 additions & 1 deletion

File tree

.github/workflows/rsr-antipattern.yml

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,115 @@ jobs:
2121
- name: Check for TypeScript
2222
run: |
2323
python3 << 'PYEOF'
24-
import re, sys, fnmatch, pathlib
24+
import re, sys, pathlib
25+
26+
# Universal allowlist — bridges and conventions that need no per-repo declaration.
27+
# Implemented as explicit string predicates rather than glob patterns so that
28+
# top-level directories (e.g. tests/foo.ts) are matched the same as nested ones,
29+
# which fnmatch's * cannot do reliably.
30+
DIR_NAMES_ALLOWED = {
31+
'bindings', 'tests', 'test', 'scripts',
32+
'mcp-adapter', 'cli', 'vendor', 'examples', 'ffi',
33+
'node_modules', 'benchmarks',
34+
}
35+
36+
def builtin_allowed(p):
37+
# `p` is a posix-style path with no leading ./
38+
# 1. Type declaration files
39+
if p.endswith('.d.ts'):
40+
return True
41+
# 2. Canonical Deno entrypoint filenames
42+
base = p.rsplit('/', 1)[-1]
43+
if base == 'mod.ts':
44+
return True
45+
# 3. LSP server files (filename suffixes)
46+
if base in ('lsp-server.ts', 'lsp_server.ts', 'lsp.ts') or base.endswith('-lsp.ts'):
47+
return True
48+
# 4. Benchmark files (filename suffixes)
49+
if base.endswith('.bench.ts') or base.endswith('_bench.ts'):
50+
return True
51+
# 5. Any directory segment (excluding basename) matches an allowed dir
52+
segs = p.split('/')
53+
for s in segs[:-1]:
54+
if s in DIR_NAMES_ALLOWED:
55+
return True
56+
# vscode-anything or anything-vscode
57+
if 'vscode' in s:
58+
return True
59+
# deno-named subprojects
60+
if s.startswith('deno-'):
61+
return True
62+
return False
63+
64+
# Per-repo exemptions parsed from .claude/CLAUDE.md "TypeScript Exemptions" table.
65+
# This is the documented single source of truth: adding one row here unblocks CI.
66+
# Glob characters: '*' and '**' both mean "any chars including /". This loose
67+
# interpretation matches user intent when an exemption row reads, e.g.,
68+
# `affinescript-deno-test/*.ts` (covering nested files too).
69+
def glob_to_regex(g):
70+
out = []
71+
for c in g.lstrip('./'):
72+
if c == '*': out.append('.*')
73+
elif c == '?': out.append('.')
74+
elif c in '.+(){}[]|^$\\': out.append(re.escape(c))
75+
else: out.append(c)
76+
return re.compile('^' + ''.join(out) + '$')
77+
78+
exemption_patterns = []
79+
claude_md = pathlib.Path('.claude/CLAUDE.md')
80+
if claude_md.exists():
81+
in_table = False
82+
for line in claude_md.read_text(encoding='utf-8').splitlines():
83+
if re.search(r'TypeScript [Ee]xemptions', line):
84+
in_table = True
85+
continue
86+
if in_table and line.startswith(('### ', '## ', '# ')):
87+
break
88+
if in_table and line.startswith('|'):
89+
m = re.match(r'\|\s*`([^`]+)`', line)
90+
if m:
91+
exemption_patterns.append((m.group(1), glob_to_regex(m.group(1))))
92+
93+
def exempt(p):
94+
for raw, regex in exemption_patterns:
95+
if regex.match(p):
96+
return True
97+
# Also allow exact-path matches and prefix matches for paths
98+
# ending in `/`
99+
if p == raw.lstrip('./'):
100+
return True
101+
if raw.endswith('/') and p.startswith(raw.lstrip('./')):
102+
return True
103+
return False
104+
105+
# Find all .ts and .tsx files (excluding common dot-dirs that find normally skips)
106+
found = []
107+
for ext in ('ts', 'tsx'):
108+
for p in pathlib.Path('.').rglob(f'*.{ext}'):
109+
parts = p.parts
110+
if any(part.startswith('.') and part not in ('.', '..') for part in parts):
111+
continue
112+
found.append(p.as_posix().lstrip('./'))
113+
114+
bad = sorted(f for f in found if not (builtin_allowed(f) or exempt(f)))
115+
if bad:
116+
print("❌ TypeScript files detected outside the allowlist.\n")
117+
for f in bad:
118+
print(f" {f}")
119+
print()
120+
print("To resolve, choose one:")
121+
print(" (a) migrate the file to AffineScript")
122+
print(" (see Human_Programming_Guide.adoc 'Migrating from -script Languages')")
123+
print(" (b) move to an allowlisted bridge path")
124+
print(" (bindings/, tests/, test/, scripts/, benchmarks/, mcp-adapter/,")
125+
print(" *vscode*/, cli/, deno-*/, vendor/, examples/, ffi/)")
126+
print(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md")
127+
print(" with rationale + unblock condition")
128+
if exemption_patterns:
129+
print(f"\n(Currently {len(exemption_patterns)} exemption(s) parsed from .claude/CLAUDE.md.)")
130+
sys.exit(1)
131+
print(f"✅ No TypeScript files outside allowlist ({len(exemption_patterns)} per-repo exemption(s) parsed).")
132+
PYEOF
25133
26134
# Universal builtin allowlist — bridges that need no per-repo declaration.
27135
# Files matching any of these patterns are always allowed.

0 commit comments

Comments
 (0)