aboutsummaryrefslogtreecommitdiff
path: root/scripts/check-boundaries.ts
blob: c438b8329e388da3eb8c4b2f227986027a40cf8c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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/ },
  { rule: "named-cast", re: /\bas\s+(?:new\s+)?[A-Z][A-Za-z0-9_]*/ },
  { rule: "function-cast", re: /\bas\s+\(/ },
  { rule: "trailing-cast", re: /\bas\s*$/ },
];

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();