Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

codeowners-lint.test.tsBlame269 lines · 1 contributor
f3e1844Claude1/**
2 * Block J21 — CODEOWNERS validator. Pure lexer + validator + route smokes.
3 */
4
5import { describe, it, expect } from "bun:test";
6import app from "../app";
7import {
8 lexCodeowners,
9 classifyOwnerToken,
10 isPlausiblePattern,
11 validateCodeowners,
12 __internal,
13 type OwnerResolver,
14} from "../lib/codeowners-lint";
15
16const allKnownResolver: OwnerResolver = {
17 isUser: () => true,
18 isTeam: () => true,
19};
20
21const noneKnownResolver: OwnerResolver = {
22 isUser: () => false,
23 isTeam: () => false,
24};
25
26function 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
38describe("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
76describe("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
102describe("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
118describe("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
255describe("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
264describe("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});