aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 19:10:30 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commit0fa62d8177f2216b95739e3d36f87fc1a95886c5 (patch)
treea81b2e461d4cf3a7af7c36d89fa6c9355e3f04bb
parentf9dcdf7f6dcdf2979693a9f179eda2b9995d57ae (diff)
chore: fail the build on unguarded type assertions
A clean tsc does not prove the absence of casts. This scans src/ for as any, ts-ignore, non-null assertions, inline object casts, and double casts, wired into a new npm run check that CI runs as a single step. The escape hatch is an external allowlist module rather than an inline comment marker: comments are not used in this codebase, and an entry carries a count, so adding a new cast to an already-exempted file still fails until someone raises a number that shows up in the diff. Exactly one exemption is needed today, for the dynamic sharp import. scripts/ was gitignored wholesale, so the two new files are un-ignored individually and everything else dropped there stays ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--.github/workflows/ci.yml7
-rw-r--r--.gitignore4
-rw-r--r--package.json2
-rw-r--r--scripts/boundary-allowlist.ts16
-rw-r--r--scripts/check-boundaries.ts105
-rw-r--r--test/util/check-boundaries-test.ts170
-rw-r--r--tsconfig.test.json2
7 files changed, 299 insertions, 7 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f2de08f..f307368 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,11 +29,8 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Typecheck
- run: npm run typecheck
-
- - name: Test
- run: npm test
+ - name: Check
+ run: npm run check
- name: Verify npm package
run: npm pack --dry-run > /dev/null
diff --git a/.gitignore b/.gitignore
index 95852a2..fcb90d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,5 +5,7 @@ benchmark/
.DS_Store
*.log
docs/
-scripts/
+scripts/*
+!scripts/check-boundaries.ts
+!scripts/boundary-allowlist.ts
data/
diff --git a/package.json b/package.json
index d21dcd6..2507382 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,8 @@
"scripts": {
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"test": "node --conditions=import --import tsx --test \"test/{profile,deep-research,domains,cdp,util}/**/*-test.ts\"",
+ "boundaries": "tsx scripts/check-boundaries.ts",
+ "check": "npm run typecheck && npm run boundaries && npm test",
"prepublishOnly": "tsc --noEmit"
},
"publishConfig": {
diff --git a/scripts/boundary-allowlist.ts b/scripts/boundary-allowlist.ts
new file mode 100644
index 0000000..be6816f
--- /dev/null
+++ b/scripts/boundary-allowlist.ts
@@ -0,0 +1,16 @@
+export type Exemption = {
+ readonly path: string;
+ readonly rule: string;
+ readonly count: number;
+ readonly reason: string;
+};
+
+export const ALLOWLIST: ReadonlyArray<Exemption> = [
+ {
+ path: "src/util/sharp-shim.ts",
+ rule: "inline-object-cast",
+ count: 1,
+ reason:
+ "sharp is an optional dependency resolved by a dynamic import, so its shape is unknown at compile time; the cast only claims an optional .default and every value read off it is re-checked with typeof before use",
+ },
+];
diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts
new file mode 100644
index 0000000..5dff96d
--- /dev/null
+++ b/scripts/check-boundaries.ts
@@ -0,0 +1,105 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { ALLOWLIST, type Exemption } from "./boundary-allowlist";
+
+export type { Exemption };
+
+export type Violation = {
+ readonly path: string;
+ readonly line: number;
+ readonly rule: string;
+ readonly text: string;
+};
+
+const RULES: ReadonlyArray<{ readonly rule: string; readonly re: RegExp }> = [
+ { rule: "as-any", re: /\bas\s+any\b/ },
+ { rule: "ts-ignore", re: /@ts-(ignore|expect-error|nocheck)/ },
+ { rule: "non-null-assertion", re: /[A-Za-z_$)\]]!\s*(?:[.[(;,)\]}]|$)/ },
+ { rule: "inline-object-cast", re: /\bas\s+\{/ },
+ { rule: "double-cast", re: /\bas\s+unknown\s+as\b/ },
+];
+
+export type SourceFile = { readonly path: string; readonly text: string };
+
+const normalise = (path: string): string => path.split("\\").join("/");
+
+const key = (path: string, rule: string): string => `${normalise(path)}\u0000${rule}`;
+
+const budgets = (exemptions: ReadonlyArray<Exemption>): Map<string, number> => {
+ const out = new Map<string, number>();
+ for (const e of exemptions) out.set(key(e.path, e.rule), (out.get(key(e.path, e.rule)) ?? 0) + e.count);
+ return out;
+};
+
+const rawHits = (files: ReadonlyArray<SourceFile>): ReadonlyArray<Violation> => {
+ const out: Violation[] = [];
+ for (const file of files) {
+ for (const [i, line] of file.text.split("\n").entries()) {
+ for (const { rule, re } of RULES) {
+ if (re.test(line)) out.push({ path: file.path, line: i + 1, rule, text: line.trim() });
+ }
+ }
+ }
+ return out;
+};
+
+export const checkSource = (
+ files: ReadonlyArray<SourceFile>,
+ exemptions: ReadonlyArray<Exemption> = ALLOWLIST,
+): ReadonlyArray<Violation> => {
+ const remaining = budgets(exemptions);
+ const out: Violation[] = [];
+ for (const hit of rawHits(files)) {
+ const k = key(hit.path, hit.rule);
+ const budget = remaining.get(k) ?? 0;
+ if (budget > 0) {
+ remaining.set(k, budget - 1);
+ continue;
+ }
+ out.push(hit);
+ }
+ return out;
+};
+
+export const unusedExemptions = (
+ files: ReadonlyArray<SourceFile>,
+ exemptions: ReadonlyArray<Exemption> = ALLOWLIST,
+): ReadonlyArray<Exemption> => {
+ const seen = new Map<string, number>();
+ for (const hit of rawHits(files)) {
+ const k = key(hit.path, hit.rule);
+ seen.set(k, (seen.get(k) ?? 0) + 1);
+ }
+ return exemptions.filter((e) => (seen.get(key(e.path, e.rule)) ?? 0) < e.count);
+};
+
+const walk = (dir: string): ReadonlyArray<string> =>
+ readdirSync(dir)
+ .sort()
+ .flatMap((name) => {
+ const p = join(dir, name);
+ return statSync(p).isDirectory() ? walk(p) : p.endsWith(".ts") ? [p] : [];
+ });
+
+const main = (): void => {
+ const files = walk("src").map((path) => ({ path: normalise(path), text: readFileSync(path, "utf8") }));
+ const violations = checkSource(files);
+ const stale = unusedExemptions(files);
+
+ for (const e of ALLOWLIST) {
+ console.log(`boundary check: exemption ${e.path} [${e.rule}] x${e.count} — ${e.reason}`);
+ }
+ for (const e of stale) {
+ console.log(`boundary check: exemption ${e.path} [${e.rule}] is stale, the code it covered is gone — delete it`);
+ }
+
+ if (violations.length === 0) {
+ console.log(`boundary check: clean (${files.length} files, ${ALLOWLIST.length} exemption(s))`);
+ return;
+ }
+ for (const v of violations) console.error(`${v.path}:${v.line} [${v.rule}] ${v.text}`);
+ console.error(`\nboundary check: ${violations.length} violation(s)`);
+ process.exitCode = 1;
+};
+
+if (process.argv[1]?.endsWith("check-boundaries.ts")) main();
diff --git a/test/util/check-boundaries-test.ts b/test/util/check-boundaries-test.ts
new file mode 100644
index 0000000..e3c2440
--- /dev/null
+++ b/test/util/check-boundaries-test.ts
@@ -0,0 +1,170 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { checkSource, unusedExemptions, type Exemption } from "../../scripts/check-boundaries";
+
+const noExemptions: ReadonlyArray<Exemption> = [];
+
+describe("boundary check", () => {
+ test("flags as any", () => {
+ const v = checkSource([{ path: "a.ts", text: "const x = y as any;" }], noExemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.rule, "as-any");
+ });
+
+ test("flags as any[]", () => {
+ const v = checkSource([{ path: "a.ts", text: "const x = y as any[];" }], noExemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.rule, "as-any");
+ });
+
+ test("flags a ts-ignore", () => {
+ const v = checkSource([{ path: "a.ts", text: "// @ts-ignore\nconst x = 1;" }], noExemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.rule, "ts-ignore");
+ });
+
+ test("flags ts-expect-error and ts-nocheck", () => {
+ const expectError = checkSource([{ path: "a.ts", text: "// @ts-expect-error" }], noExemptions);
+ const noCheck = checkSource([{ path: "a.ts", text: "// @ts-nocheck" }], noExemptions);
+ assert.equal(expectError.length, 1);
+ assert.equal(noCheck.length, 1);
+ });
+
+ test("flags a non-null assertion before a property access", () => {
+ const v = checkSource([{ path: "a.ts", text: "const w = data.model!.width;" }], noExemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.rule, "non-null-assertion");
+ });
+
+ test("flags a non-null assertion with no property access after it", () => {
+ const forms = ["const a = b!;", "f(g!);", "const c = arr[0]!;", "const d = [x!, y];", "const e = obj\n .a!\n .b;"];
+ for (const text of forms) {
+ const v = checkSource([{ path: "a.ts", text }], noExemptions);
+ assert.equal(v.length, 1, text);
+ assert.equal(v[0]?.rule, "non-null-assertion", text);
+ }
+ });
+
+ test("does not flag strict inequality, spaced or unspaced", () => {
+ for (const text of ["if (a !== b.c) return;", "if (a!==b.c) return;", "if (a != b.c) return;", "if (a!=b.c) return;"]) {
+ assert.equal(checkSource([{ path: "a.ts", text }], noExemptions).length, 0, text);
+ }
+ });
+
+ test("does not flag boolean negation", () => {
+ for (const text of ["if (!ok.value) return;", "const n = a && !b.c;", "return !this.done;", "const m = [!x.y];"]) {
+ assert.equal(checkSource([{ path: "a.ts", text }], noExemptions).length, 0, text);
+ }
+ });
+
+ test("does not flag an exclamation mark in prose", () => {
+ const text = 'description: "Done! Use browser_snapshot as the default. Options: any of these."';
+ assert.equal(checkSource([{ path: "a.ts", text }], noExemptions).length, 0);
+ });
+
+ test("flags an inline object cast", () => {
+ const v = checkSource([{ path: "a.ts", text: "const d = r.data as { sessionId: string };" }], noExemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.rule, "inline-object-cast");
+ });
+
+ test("flags a double cast", () => {
+ const v = checkSource([{ path: "a.ts", text: "const d = r as unknown as Session;" }], noExemptions);
+ assert.equal(v.some((x) => x.rule === "double-cast"), true);
+ });
+
+ test("allows as const", () => {
+ assert.equal(checkSource([{ path: "a.ts", text: "const x = [1] as const;" }], noExemptions).length, 0);
+ });
+
+ test("allows a cast to a named type", () => {
+ assert.equal(checkSource([{ path: "a.ts", text: "return ok(raw as ResultOf<M>);" }], noExemptions).length, 0);
+ });
+
+ test("reports the 1-indexed line number and the trimmed text", () => {
+ const v = checkSource([{ path: "a.ts", text: "const a = 1;\n const b = c as any;" }], noExemptions);
+ assert.equal(v[0]?.line, 2);
+ assert.equal(v[0]?.text, "const b = c as any;");
+ assert.equal(v[0]?.path, "a.ts");
+ });
+
+ test("scans every file it is given", () => {
+ const v = checkSource(
+ [
+ { path: "a.ts", text: "const x = y as any;" },
+ { path: "b.ts", text: "const z = w as any;" },
+ ],
+ noExemptions,
+ );
+ assert.deepEqual(
+ v.map((x) => x.path),
+ ["a.ts", "b.ts"],
+ );
+ });
+});
+
+describe("boundary check exemptions", () => {
+ const exemptions: ReadonlyArray<Exemption> = [
+ { path: "a.ts", rule: "inline-object-cast", count: 1, reason: "narrows an untyped dynamic import" },
+ ];
+
+ test("permits up to count violations for the exempted path and rule", () => {
+ const v = checkSource([{ path: "a.ts", text: "const m = mod as { default?: unknown };" }], exemptions);
+ assert.equal(v.length, 0);
+ });
+
+ test("reports the count+1-th violation so new casts cannot hide behind an exemption", () => {
+ const text = "const m = mod as { default?: unknown };\nconst n = other as { x?: unknown };";
+ const v = checkSource([{ path: "a.ts", text }], exemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.line, 2);
+ assert.equal(v[0]?.rule, "inline-object-cast");
+ });
+
+ test("does not exempt a different rule in the same file", () => {
+ const v = checkSource([{ path: "a.ts", text: "const x = y as any;" }], exemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.rule, "as-any");
+ });
+
+ test("does not exempt the same rule in a different file", () => {
+ const v = checkSource([{ path: "b.ts", text: "const m = mod as { default?: unknown };" }], exemptions);
+ assert.equal(v.length, 1);
+ assert.equal(v[0]?.path, "b.ts");
+ });
+
+ test("counts exempted violations per file, not across all files", () => {
+ const wide: ReadonlyArray<Exemption> = [
+ { path: "a.ts", rule: "as-any", count: 1, reason: "one" },
+ { path: "b.ts", rule: "as-any", count: 1, reason: "two" },
+ ];
+ const v = checkSource(
+ [
+ { path: "a.ts", text: "const x = y as any;" },
+ { path: "b.ts", text: "const z = w as any;" },
+ ],
+ wide,
+ );
+ assert.equal(v.length, 0);
+ });
+
+ test("normalises windows path separators before matching", () => {
+ const v = checkSource([{ path: "src\\util\\a.ts", text: "const x = y as any;" }], [
+ { path: "src/util/a.ts", rule: "as-any", count: 1, reason: "platform independent" },
+ ]);
+ assert.equal(v.length, 0);
+ });
+
+ test("unusedExemptions names exemptions no longer earning their keep", () => {
+ const stale = unusedExemptions([{ path: "a.ts", text: "const x = 1;" }], exemptions);
+ assert.deepEqual(
+ stale.map((e) => e.rule),
+ ["inline-object-cast"],
+ );
+ });
+
+ test("unusedExemptions is empty when every exemption is consumed", () => {
+ const used = unusedExemptions([{ path: "a.ts", text: "const m = mod as { default?: unknown };" }], exemptions);
+ assert.deepEqual(used, []);
+ });
+});
diff --git a/tsconfig.test.json b/tsconfig.test.json
index b9d97a4..30d17f6 100644
--- a/tsconfig.test.json
+++ b/tsconfig.test.json
@@ -4,6 +4,6 @@
"noEmit": true,
"types": ["node"]
},
- "include": ["src/**/*.ts", "test/**/*.ts"],
+ "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"],
"exclude": ["node_modules", "dist"]
}