Blame · Line-by-line history
codeowners-lint.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f3e1844 | 1 | /** |
| 2 | * Block J21 — CODEOWNERS validator. Pure lexer + validator + route smokes. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import app from "../app"; | |
| 7 | import { | |
| 8 | lexCodeowners, | |
| 9 | classifyOwnerToken, | |
| 10 | isPlausiblePattern, | |
| 11 | validateCodeowners, | |
| 12 | __internal, | |
| 13 | type OwnerResolver, | |
| 14 | } from "../lib/codeowners-lint"; | |
| 15 | ||
| 16 | const allKnownResolver: OwnerResolver = { | |
| 17 | isUser: () => true, | |
| 18 | isTeam: () => true, | |
| 19 | }; | |
| 20 | ||
| 21 | const noneKnownResolver: OwnerResolver = { | |
| 22 | isUser: () => false, | |
| 23 | isTeam: () => false, | |
| 24 | }; | |
| 25 | ||
| 26 | function resolver(known: { | |
| 27 | users?: string[]; | |
| 28 | teams?: string[]; | |
| 29 | }): OwnerResolver { | |
| 30 | const u = new Set((known.users || []).map((s) => s.toLowerCase())); | |
| 31 | const t = new Set((known.teams || []).map((s) => s.toLowerCase())); | |
| 32 | return { | |
| 33 | isUser: (x) => u.has(x.toLowerCase()), | |
| 34 | isTeam: (o, team) => t.has(`${o}/${team}`.toLowerCase()), | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | describe("codeowners-lint — lexCodeowners", () => { | |
| 39 | it("ignores blank + comment lines", () => { | |
| 40 | const { rules, totalLines } = lexCodeowners( | |
| 41 | "# comment\n\n# another\n* @alice\n" | |
| 42 | ); | |
| 43 | expect(rules).toHaveLength(1); | |
| 44 | expect(rules[0].line).toBe(4); | |
| 45 | expect(rules[0].pattern).toBe("*"); | |
| 46 | expect(rules[0].owners).toEqual(["alice"]); | |
| 47 | expect(totalLines).toBeGreaterThan(0); | |
| 48 | }); | |
| 49 | ||
| 50 | it("strips the leading @ from owner tokens", () => { | |
| 51 | const { rules } = lexCodeowners("* @alice @bob\n"); | |
| 52 | expect(rules[0].owners).toEqual(["alice", "bob"]); | |
| 53 | }); | |
| 54 | ||
| 55 | it("detects malformed lines with no owners", () => { | |
| 56 | const { malformedLines } = lexCodeowners("*\nfoo/*\n"); | |
| 57 | expect(malformedLines.map((m) => m.line)).toEqual([1, 2]); | |
| 58 | }); | |
| 59 | ||
| 60 | it("preserves line numbers with CRLF", () => { | |
| 61 | const { rules } = lexCodeowners("# comment\r\n\r\n* @a\r\n"); | |
| 62 | expect(rules[0].line).toBe(3); | |
| 63 | }); | |
| 64 | ||
| 65 | it("drops inline trailing comments", () => { | |
| 66 | const { rules } = lexCodeowners("src/api/** @bob # backend lead"); | |
| 67 | expect(rules[0].owners).toEqual(["bob"]); | |
| 68 | }); | |
| 69 | ||
| 70 | it("accepts team-style owner tokens", () => { | |
| 71 | const { rules } = lexCodeowners("docs/ @acme/docs-team"); | |
| 72 | expect(rules[0].owners).toEqual(["acme/docs-team"]); | |
| 73 | }); | |
| 74 | }); | |
| 75 | ||
| 76 | describe("codeowners-lint — classifyOwnerToken", () => { | |
| 77 | it("classifies @user tokens as user", () => { | |
| 78 | expect(classifyOwnerToken("alice", true)).toBe("user"); | |
| 79 | expect(classifyOwnerToken("a", true)).toBe("user"); | |
| 80 | expect(classifyOwnerToken("alice-bob", true)).toBe("user"); | |
| 81 | }); | |
| 82 | ||
| 83 | it("classifies @org/team tokens as team", () => { | |
| 84 | expect(classifyOwnerToken("acme/backend", true)).toBe("team"); | |
| 85 | expect(classifyOwnerToken("acme/docs-team_2", true)).toBe("team"); | |
| 86 | }); | |
| 87 | ||
| 88 | it("classifies plain emails as email", () => { | |
| 89 | expect(classifyOwnerToken("a@b.com", false)).toBe("email"); | |
| 90 | expect(classifyOwnerToken("a.b+c@mail.example.io", false)).toBe("email"); | |
| 91 | }); | |
| 92 | ||
| 93 | it("rejects bogus tokens", () => { | |
| 94 | expect(classifyOwnerToken("", true)).toBe("invalid"); | |
| 95 | expect(classifyOwnerToken("-alice", true)).toBe("invalid"); // leading dash | |
| 96 | expect(classifyOwnerToken("alice/bob/carol", true)).toBe("invalid"); // two slashes | |
| 97 | expect(classifyOwnerToken("alice!", true)).toBe("invalid"); | |
| 98 | expect(classifyOwnerToken("plain", false)).toBe("invalid"); // missing @ | |
| 99 | }); | |
| 100 | }); | |
| 101 | ||
| 102 | describe("codeowners-lint — isPlausiblePattern", () => { | |
| 103 | it("accepts normal glob patterns", () => { | |
| 104 | expect(isPlausiblePattern("*")).toBe(true); | |
| 105 | expect(isPlausiblePattern("/docs/**")).toBe(true); | |
| 106 | expect(isPlausiblePattern("src/**/*.ts")).toBe(true); | |
| 107 | expect(isPlausiblePattern("[abc].md")).toBe(true); | |
| 108 | }); | |
| 109 | ||
| 110 | it("rejects empty, whitespace, unbalanced brackets", () => { | |
| 111 | expect(isPlausiblePattern("")).toBe(false); | |
| 112 | expect(isPlausiblePattern("foo bar")).toBe(false); | |
| 113 | expect(isPlausiblePattern("[abc")).toBe(false); | |
| 114 | expect(isPlausiblePattern("abc]")).toBe(false); | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 118 | describe("codeowners-lint — validateCodeowners", () => { | |
| 119 | it("flags empty files as a warning", async () => { | |
| 120 | const r = await validateCodeowners("", allKnownResolver); | |
| 121 | expect(r.ok).toBe(true); | |
| 122 | expect(r.warnings).toHaveLength(1); | |
| 123 | expect(r.warnings[0].code).toBe("empty_file"); | |
| 124 | }); | |
| 125 | ||
| 126 | it("returns ok + a single info for a valid-but-missing-catchall file", async () => { | |
| 127 | const r = await validateCodeowners( | |
| 128 | "/docs/ @alice\n", | |
| 129 | resolver({ users: ["alice"] }) | |
| 130 | ); | |
| 131 | expect(r.errors).toHaveLength(0); | |
| 132 | expect(r.infos.some((f) => f.code === "missing_catchall")).toBe(true); | |
| 133 | expect(r.ok).toBe(true); | |
| 134 | }); | |
| 135 | ||
| 136 | it("clean file with catchall yields zero findings", async () => { | |
| 137 | const r = await validateCodeowners( | |
| 138 | "* @alice\n", | |
| 139 | resolver({ users: ["alice"] }) | |
| 140 | ); | |
| 141 | expect(r.findings).toEqual([]); | |
| 142 | expect(r.ok).toBe(true); | |
| 143 | }); | |
| 144 | ||
| 145 | it("catches pattern-with-no-owners", async () => { | |
| 146 | const r = await validateCodeowners( | |
| 147 | "* @alice\n/docs/\n", | |
| 148 | resolver({ users: ["alice"] }) | |
| 149 | ); | |
| 150 | const noOwners = r.findings.find((f) => f.code === "no_owners"); | |
| 151 | expect(noOwners).toBeTruthy(); | |
| 152 | expect(noOwners!.line).toBe(2); | |
| 153 | expect(r.ok).toBe(false); | |
| 154 | }); | |
| 155 | ||
| 156 | it("reports unknown users", async () => { | |
| 157 | const r = await validateCodeowners( | |
| 158 | "* @ghost\n", | |
| 159 | noneKnownResolver | |
| 160 | ); | |
| 161 | const unknown = r.findings.find((f) => f.code === "unknown_user"); | |
| 162 | expect(unknown).toBeTruthy(); | |
| 163 | expect(unknown!.token).toBe("ghost"); | |
| 164 | expect(r.ok).toBe(false); | |
| 165 | }); | |
| 166 | ||
| 167 | it("reports unknown teams", async () => { | |
| 168 | const r = await validateCodeowners( | |
| 169 | "* @acme/missing\n", | |
| 170 | resolver({}) | |
| 171 | ); | |
| 172 | const unknown = r.findings.find((f) => f.code === "unknown_team"); | |
| 173 | expect(unknown).toBeTruthy(); | |
| 174 | expect(unknown!.token).toBe("acme/missing"); | |
| 175 | }); | |
| 176 | ||
| 177 | it("accepts plain email owners without DB lookup", async () => { | |
| 178 | const r = await validateCodeowners( | |
| 179 | "* ops@example.com\n", | |
| 180 | noneKnownResolver | |
| 181 | ); | |
| 182 | const errors = r.errors.filter( | |
| 183 | (f) => | |
| 184 | f.code === "unknown_user" || | |
| 185 | f.code === "unknown_team" || | |
| 186 | f.code === "bad_owner_format" | |
| 187 | ); | |
| 188 | expect(errors).toHaveLength(0); | |
| 189 | expect(r.ok).toBe(true); | |
| 190 | }); | |
| 191 | ||
| 192 | it("flags bad owner formats", async () => { | |
| 193 | const r = await validateCodeowners( | |
| 194 | "* @-bad-user\n", | |
| 195 | allKnownResolver | |
| 196 | ); | |
| 197 | const bad = r.findings.find((f) => f.code === "bad_owner_format"); | |
| 198 | expect(bad).toBeTruthy(); | |
| 199 | }); | |
| 200 | ||
| 201 | it("flags duplicate patterns as warnings", async () => { | |
| 202 | const r = await validateCodeowners( | |
| 203 | "* @alice\n* @bob\n", | |
| 204 | resolver({ users: ["alice", "bob"] }) | |
| 205 | ); | |
| 206 | const dup = r.findings.find((f) => f.code === "duplicate_pattern"); | |
| 207 | expect(dup).toBeTruthy(); | |
| 208 | expect(dup!.severity).toBe("warning"); | |
| 209 | }); | |
| 210 | ||
| 211 | it("flags duplicate owners on the same rule as warnings", async () => { | |
| 212 | const r = await validateCodeowners( | |
| 213 | "* @alice @alice\n", | |
| 214 | resolver({ users: ["alice"] }) | |
| 215 | ); | |
| 216 | const dup = r.findings.find((f) => f.code === "duplicate_owner"); | |
| 217 | expect(dup).toBeTruthy(); | |
| 218 | expect(dup!.severity).toBe("warning"); | |
| 219 | }); | |
| 220 | ||
| 221 | it("orders findings by line number", async () => { | |
| 222 | const r = await validateCodeowners( | |
| 223 | "* @alice @alice\n/bad\n/docs/ @ghost\n", | |
| 224 | resolver({ users: ["alice"] }) | |
| 225 | ); | |
| 226 | const lines = r.findings.map((f) => f.line); | |
| 227 | const sorted = [...lines].sort((a, b) => a - b); | |
| 228 | expect(lines).toEqual(sorted); | |
| 229 | }); | |
| 230 | ||
| 231 | it("returns ok=false when any error is present", async () => { | |
| 232 | const r = await validateCodeowners("/docs/\n", resolver({})); | |
| 233 | expect(r.ok).toBe(false); | |
| 234 | }); | |
| 235 | ||
| 236 | it("returns ok=true when only warnings/infos exist", async () => { | |
| 237 | const r = await validateCodeowners( | |
| 238 | "/docs/ @alice\n", | |
| 239 | resolver({ users: ["alice"] }) | |
| 240 | ); | |
| 241 | expect(r.warnings.length + r.infos.length).toBeGreaterThan(0); | |
| 242 | expect(r.ok).toBe(true); | |
| 243 | }); | |
| 244 | ||
| 245 | it("flags bad pattern syntax with unbalanced brackets", async () => { | |
| 246 | const r = await validateCodeowners( | |
| 247 | "[abc @alice\n", | |
| 248 | resolver({ users: ["alice"] }) | |
| 249 | ); | |
| 250 | const bad = r.findings.find((f) => f.code === "bad_pattern_syntax"); | |
| 251 | expect(bad).toBeTruthy(); | |
| 252 | }); | |
| 253 | }); | |
| 254 | ||
| 255 | describe("codeowners-lint — __internal parity", () => { | |
| 256 | it("re-exports the helpers", () => { | |
| 257 | expect(__internal.lexCodeowners).toBe(lexCodeowners); | |
| 258 | expect(__internal.classifyOwnerToken).toBe(classifyOwnerToken); | |
| 259 | expect(__internal.isPlausiblePattern).toBe(isPlausiblePattern); | |
| 260 | expect(__internal.validateCodeowners).toBe(validateCodeowners); | |
| 261 | }); | |
| 262 | }); | |
| 263 | ||
| 264 | describe("codeowners-lint — routes", () => { | |
| 265 | it("GET /:o/:r/codeowners returns 404 for unknown repos", async () => { | |
| 266 | const res = await app.request("/alice/nope/codeowners"); | |
| 267 | expect(res.status).toBe(404); | |
| 268 | }); | |
| 269 | }); |