diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 19:10:30 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | 0fa62d8177f2216b95739e3d36f87fc1a95886c5 (patch) | |
| tree | a81b2e461d4cf3a7af7c36d89fa6c9355e3f04bb | |
| parent | f9dcdf7f6dcdf2979693a9f179eda2b9995d57ae (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.yml | 7 | ||||
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | scripts/boundary-allowlist.ts | 16 | ||||
| -rw-r--r-- | scripts/check-boundaries.ts | 105 | ||||
| -rw-r--r-- | test/util/check-boundaries-test.ts | 170 | ||||
| -rw-r--r-- | tsconfig.test.json | 2 |
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 @@ -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"] } |
