Skip to content

Commit 0809b8e

Browse files
[codex] fix TS allowlist path normalization (#395)
## Root cause The current compiled checker walks files as `./repo-relative/path`, while exemptions parsed from `.claude/CLAUDE.md` are stored as `repo-relative/path`. That meant exact-path exemptions such as `rescript-ecosystem/packages/core/runtime-tools/bin/rrt.ts` did not match the generated `.deno.js` runtime artifact even though the table row was parsed. The prime backtick hypothesis was not the full issue in the current source: the row parser already extracted code-span contents. The fix still normalizes parsed exemption cells by trimming and storing repo-relative paths, and it normalizes candidate paths before exact/glob matching. ## Fix - Normalize exemption paths and matched file paths before comparison so `./path` and `path` compare as the same repo-relative file. - Keep exact entries exact and glob entries regex-backed; no substring fallback was added. - Keep header/separator rows out of the parsed exemption set. - Treat `.ts.bak` / `.tsx.bak` as blocked TS artifacts so near-miss exemption paths do not slip through. - Expand the regression harness to run both `scripts/check-ts-allowlist.ts` and the compiled `scripts/check-ts-allowlist.deno.js` artifact. ## Validation - `scripts/tests/check-ts-allowlist-test.sh` passes: 36 assertions across both checker targets. - Focused repro with `rescript-ecosystem/packages/core/runtime-tools/bin/rrt.ts` and the approved CLAUDE.md table is clean after the fix. - `just check-ts-allowlist-drift` is not clean locally because the current AffineScript compiler also fails to parse the unmodified HEAD source, and the recipe mishandles its temp variables after that failure. The committed `.deno.js` artifact was therefore patched and validated directly. ## Consumer follow-up New standards commit SHA to pin for this change: `681373b30b3615460ac3c802d6ba8db893b48d45`. Consumers that pin `hyperpolymath/standards/.github/workflows/governance-reusable.yml@<sha>` need a follow-up pin bump after this lands, including `developer-ecosystem` and the k9 repos. Note that current `main` has drifted from `861b5e911d9e5dcfb3c0ab3dd2a9a3c8fd0a1613`; verify the reusable workflow shape when bumping consumers.
1 parent d72fe5a commit 0809b8e

4 files changed

Lines changed: 174 additions & 60 deletions

File tree

scripts/check-ts-allowlist.affine

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ fn globToRegex(g: String) -> String {
8888

8989
type Exemption = { raw: String, rx: String }
9090

91+
fn normalizeRepoPath(p: String) -> String {
92+
let mut out = p;
93+
while (len(out) > 0 && (string_sub(out, 0, 1) == "." || string_sub(out, 0, 1) == "/")) {
94+
out = string_sub(out, 1, len(out) - 1);
95+
}
96+
return out;
97+
}
98+
9199
fn loadExemptionsFromClaudeMd() -> [Exemption] {
92100
let mut exemptions = [];
93101
let text = try {
@@ -116,11 +124,11 @@ fn loadExemptionsFromClaudeMd() -> [Exemption] {
116124
i = i + 1;
117125
continue;
118126
}
119-
if (inTable && len(line) > 0 && string_sub(line, 0, 1) == "|") {
120-
if (regexMatch(line, "^\\|\\s*`[^`]+`")) {
127+
if (inTable && len(line) > 0) {
128+
if (regexMatch(line, "^\\s*\\|\\s*`[^`]+`")) {
121129
let parts = split(line, "`");
122130
if (len(parts) >= 3) {
123-
let raw = parts[1];
131+
let raw = normalizeRepoPath(trim(parts[1]));
124132
exemptions = exemptions ++ [#{ raw: raw, rx: globToRegex(raw) }];
125133
}
126134
}
@@ -144,7 +152,7 @@ fn loadExemptionsFromAllowlistFile() -> [Exemption] {
144152
let lines_len = len(lines);
145153
while (i < lines_len) {
146154
let rawLine = lines[i];
147-
let line = trim(rawLine);
155+
let line = normalizeRepoPath(trim(rawLine));
148156
if (line == "" || string_sub(line, 0, 1) == "#") {
149157
i = i + 1;
150158
continue;
@@ -160,22 +168,28 @@ fn loadExemptions() -> [Exemption] {
160168
}
161169

162170
fn isExempt(p: String, exemptions: [Exemption]) -> Bool {
171+
let target = normalizeRepoPath(trim(p));
163172
let mut i = 0;
164173
let ex_len = len(exemptions);
165174
while (i < ex_len) {
166175
let e = exemptions[i];
167-
if (regexMatch(p, e.rx)) { return true; }
168-
let mut bare = e.raw;
169-
while (len(bare) > 0 && (string_sub(bare, 0, 1) == "." || string_sub(bare, 0, 1) == "/")) {
170-
bare = string_sub(bare, 1, len(bare) - 1);
171-
}
172-
if (p == bare) { return true; }
173-
if (ends_with(e.raw, "/") && regexMatch(p, "^" ++ bare)) { return true; }
176+
if (regexMatch(target, e.rx)) { return true; }
177+
let bare = normalizeRepoPath(trim(e.raw));
178+
if (target == bare) { return true; }
179+
if (ends_with(bare, "/") && regexMatch(target, "^" ++ bare)) { return true; }
174180
i = i + 1;
175181
}
176182
return false;
177183
}
178184

185+
fn isTypeScriptArtifact(name: String) -> Bool {
186+
if (ends_with(name, ".ts")) { return true; }
187+
if (ends_with(name, ".tsx")) { return true; }
188+
if (ends_with(name, ".ts.bak")) { return true; }
189+
if (ends_with(name, ".tsx.bak")) { return true; }
190+
return false;
191+
}
192+
179193
pub fn main() -> Int {
180194
let exemptions = loadExemptions();
181195
let mut found = [];
@@ -189,7 +203,7 @@ pub fn main() -> Int {
189203
let af_len = len(all_files);
190204
while (i < af_len) {
191205
let f = all_files[i];
192-
if (ends_with(f, ".ts") || ends_with(f, ".tsx")) {
206+
if (isTypeScriptArtifact(f)) {
193207
let mut skip = false;
194208
let segs = split(f, "/");
195209
let mut j = 0;

scripts/check-ts-allowlist.deno.js

Lines changed: 20 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/check-ts-allowlist.ts

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MPL-2.0
2-
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
33
//
44
// check-ts-allowlist.ts — Deno port of the inline python3 heredoc that used
55
// to live in `.github/workflows/governance-reusable.yml` step
@@ -12,7 +12,8 @@
1212
// self-loop fixed in hypatia#328. This script eliminates the violation.
1313
//
1414
// Behaviour MUST stay byte-identical to the previous Python implementation:
15-
// * Walk every `*.ts` / `*.tsx` file under cwd, skipping dotted dirs.
15+
// * Walk every `*.ts` / `*.tsx` file under cwd, skipping dotted dirs
16+
// and treating `.ts.bak` / `.tsx.bak` backups as banned TS artifacts.
1617
// * Allow files in the built-in directory/path allowlist
1718
// (bindings/tests/scripts/vendor/examples/ffi/benchmarks/cli, plus any
1819
// segment containing 'vscode' or starting with 'deno-').
@@ -72,6 +73,27 @@ function globToRegex(g: string): RegExp {
7273

7374
interface Exemption { raw: string; rx: RegExp; }
7475

76+
function normalizeRepoPath(p: string): string {
77+
let out = p.trim();
78+
while (out.length > 0 && (out[0] === "." || out[0] === "/")) {
79+
out = out.slice(1);
80+
}
81+
return out;
82+
}
83+
84+
function normalizeExemptionCell(cell: string): string {
85+
let out = cell.trim();
86+
const codeSpan = out.match(/^`([^`]+)`$/) ?? out.match(/^`([^`]+)`/);
87+
if (codeSpan) {
88+
out = codeSpan[1].trim();
89+
}
90+
return normalizeRepoPath(out);
91+
}
92+
93+
function nonExemptionCell(cell: string): boolean {
94+
return cell === "" || /^:?-{3,}:?$/.test(cell) || /^path\b/i.test(cell);
95+
}
96+
7597
async function loadExemptionsFromClaudeMd(): Promise<Exemption[]> {
7698
// Layer 2 — heading-table exemptions parsed from `.claude/CLAUDE.md`.
7799
//
@@ -110,10 +132,14 @@ async function loadExemptionsFromClaudeMd(): Promise<Exemption[]> {
110132
inTable = false;
111133
continue;
112134
}
113-
if (inTable && line.startsWith("|")) {
114-
const m = line.match(/^\|\s*`([^`]+)`/);
115-
if (m) {
116-
exemptions.push({ raw: m[1], rx: globToRegex(m[1]) });
135+
const tableLine = line.trim();
136+
if (inTable && tableLine.startsWith("|")) {
137+
const cells = tableLine.split("|");
138+
if (cells.length >= 3) {
139+
const raw = normalizeExemptionCell(cells[1]);
140+
if (!nonExemptionCell(raw)) {
141+
exemptions.push({ raw, rx: globToRegex(raw) });
142+
}
117143
}
118144
}
119145
}
@@ -135,7 +161,7 @@ async function loadExemptionsFromAllowlistFile(): Promise<Exemption[]> {
135161
return exemptions;
136162
}
137163
for (const rawLine of text.split("\n")) {
138-
const line = rawLine.trim();
164+
const line = normalizeExemptionCell(rawLine);
139165
if (line === "" || line.startsWith("#")) continue;
140166
exemptions.push({ raw: line, rx: globToRegex(line) });
141167
}
@@ -149,16 +175,21 @@ async function loadExemptions(): Promise<Exemption[]> {
149175
}
150176

151177
function exempt(p: string, exemptions: Exemption[]): boolean {
178+
const target = normalizeRepoPath(p);
152179
for (const e of exemptions) {
153-
if (e.rx.test(p)) return true;
154-
let bare = e.raw;
155-
while (bare.length > 0 && (bare[0] === "." || bare[0] === "/")) bare = bare.slice(1);
156-
if (p === bare) return true;
157-
if (e.raw.endsWith("/") && p.startsWith(bare)) return true;
180+
if (e.rx.test(target)) return true;
181+
const bare = normalizeRepoPath(e.raw);
182+
if (target === bare) return true;
183+
if (bare.endsWith("/") && target.startsWith(bare)) return true;
158184
}
159185
return false;
160186
}
161187

188+
function isTypeScriptArtifact(name: string): boolean {
189+
return name.endsWith(".ts") || name.endsWith(".tsx") ||
190+
name.endsWith(".ts.bak") || name.endsWith(".tsx.bak");
191+
}
192+
162193
async function* walkTs(dir: string): AsyncIterable<string> {
163194
for await (const entry of Deno.readDir(dir)) {
164195
const name = entry.name;
@@ -168,7 +199,7 @@ async function* walkTs(dir: string): AsyncIterable<string> {
168199
if (entry.isDirectory) {
169200
yield* walkTs(full);
170201
} else if (entry.isFile) {
171-
if (name.endsWith(".ts") || name.endsWith(".tsx")) {
202+
if (isTypeScriptArtifact(name)) {
172203
yield full;
173204
}
174205
}

0 commit comments

Comments
 (0)