Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

codeowners-lint.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.tsBlame345 lines · 1 contributor
f3e1844Claude1/**
2 * Block J21 — CODEOWNERS validator.
3 *
4 * Pure lint layer for CODEOWNERS files. Splits input line-by-line,
5 * classifies each line (comment / blank / rule / malformed), and returns
6 * a typed report with errors + warnings anchored to 1-indexed line
7 * numbers so the UI can point the user at the exact problem.
8 *
9 * Validation is *local* (structure + obviously-bogus tokens) except for
10 * `unknownUser` / `unknownTeam`, which require a caller-supplied
11 * resolver so this module stays IO-free and easy to unit-test.
12 */
13
14export type LintSeverity = "error" | "warning" | "info";
15
16export type LintCode =
17 | "empty_pattern"
18 | "no_owners"
19 | "bad_owner_format"
20 | "unknown_user"
21 | "unknown_team"
22 | "duplicate_pattern"
23 | "duplicate_owner"
24 | "missing_catchall"
25 | "empty_file"
26 | "bad_pattern_syntax";
27
28export interface LintFinding {
29 line: number;
30 code: LintCode;
31 severity: LintSeverity;
32 message: string;
33 /** Optional pattern + offending token for the UI to highlight. */
34 pattern?: string;
35 token?: string;
36}
37
38export interface LexedRule {
39 line: number;
40 /** The raw line text (without trailing `\n`). */
41 raw: string;
42 pattern: string;
43 /** Owner tokens **without** leading `@`, in source order. */
44 owners: string[];
45}
46
47export interface LintReport {
48 totalLines: number;
49 totalRules: number;
50 rules: LexedRule[];
51 findings: LintFinding[];
52 errors: LintFinding[];
53 warnings: LintFinding[];
54 infos: LintFinding[];
55 /** Convenience: true iff `errors.length === 0`. */
56 ok: boolean;
57}
58
59/**
60 * Resolver contract — the route fulfils this from the DB / org model so
61 * the pure lint logic remains free of external dependencies.
62 */
63export interface OwnerResolver {
64 isUser: (username: string) => boolean | Promise<boolean>;
65 isTeam: (org: string, team: string) => boolean | Promise<boolean>;
66}
67
68// ---------------------------------------------------------------------------
69// Lexer
70// ---------------------------------------------------------------------------
71
72/**
73 * Split content into per-line records. Comment and blank lines are kept
74 * in the return only so callers can display the original file alongside
75 * findings — they produce no findings themselves.
76 */
77export function lexCodeowners(content: string): {
78 rules: LexedRule[];
79 malformedLines: Array<{ line: number; raw: string }>;
80 totalLines: number;
81} {
82 const rules: LexedRule[] = [];
83 const malformed: Array<{ line: number; raw: string }> = [];
84 const lines = content.split(/\r?\n/);
85 lines.forEach((raw, idx) => {
86 const lineNo = idx + 1;
87 const trimmed = raw.replace(/#.*$/, "").trim();
88 if (!trimmed) return; // blank or pure comment
89 const parts = trimmed.split(/\s+/);
90 if (parts.length === 1) {
91 // A pattern with zero owners — malformed rule.
92 malformed.push({ line: lineNo, raw });
93 return;
94 }
95 const pattern = parts[0];
96 const owners = parts
97 .slice(1)
98 .map((o) => o.replace(/^@/, "").trim())
99 .filter(Boolean);
100 rules.push({ line: lineNo, raw, pattern, owners });
101 });
102 return { rules, malformedLines: malformed, totalLines: lines.length };
103}
104
105// ---------------------------------------------------------------------------
106// Owner-token classification
107// ---------------------------------------------------------------------------
108
109export type OwnerTokenKind = "user" | "team" | "email" | "invalid";
110
111const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
112const USER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
113const TEAM_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9_-]{0,38}$/;
114
115export function classifyOwnerToken(
116 tokenWithoutAt: string,
117 hadAt: boolean
118): OwnerTokenKind {
119 if (!tokenWithoutAt) return "invalid";
120 if (!hadAt && EMAIL_RE.test(tokenWithoutAt)) return "email";
121 if (!hadAt) return "invalid"; // non-@ tokens must be emails
122 if (tokenWithoutAt.includes("/")) {
123 return TEAM_RE.test(tokenWithoutAt) ? "team" : "invalid";
124 }
125 return USER_RE.test(tokenWithoutAt) ? "user" : "invalid";
126}
127
128// ---------------------------------------------------------------------------
129// Pattern sanity check
130// ---------------------------------------------------------------------------
131
132/**
133 * GitHub's CODEOWNERS patterns are a restricted subset of gitignore-style
134 * globs. This catches the obviously-broken cases without trying to be a
135 * full parser — we delegate actual matching to the existing parser.
136 */
137export function isPlausiblePattern(pat: string): boolean {
138 if (!pat) return false;
139 if (/\s/.test(pat)) return false;
140 // No unmatched brackets.
141 let depth = 0;
142 for (const ch of pat) {
143 if (ch === "[") depth++;
144 if (ch === "]") depth--;
145 if (depth < 0) return false;
146 }
147 if (depth !== 0) return false;
148 return true;
149}
150
151// ---------------------------------------------------------------------------
152// Validator
153// ---------------------------------------------------------------------------
154
155export async function validateCodeowners(
156 content: string,
157 resolver: OwnerResolver
158): Promise<LintReport> {
159 const lex = lexCodeowners(content);
160 const findings: LintFinding[] = [];
161
162 // 1. Empty-file case.
163 if (content.trim().length === 0) {
164 findings.push({
165 line: 1,
166 code: "empty_file",
167 severity: "warning",
168 message:
169 "CODEOWNERS file is empty — no ownership rules are being enforced.",
170 });
171 return assembleReport(lex.rules, findings, lex.totalLines);
172 }
173
174 // 2. Pattern-with-no-owners lines.
175 for (const m of lex.malformedLines) {
176 findings.push({
177 line: m.line,
178 code: "no_owners",
179 severity: "error",
180 message:
181 "Rule declares a pattern but no owners. Each rule must list at least one @user, @org/team, or email.",
182 });
183 }
184
185 // 3. Per-rule checks.
186 const seenPatterns = new Map<string, number>();
187 for (const rule of lex.rules) {
188 // a) Empty / malformed pattern.
189 if (!rule.pattern) {
190 findings.push({
191 line: rule.line,
192 code: "empty_pattern",
193 severity: "error",
194 message: "Rule has no pattern.",
195 });
196 continue;
197 }
198 if (!isPlausiblePattern(rule.pattern)) {
199 findings.push({
200 line: rule.line,
201 code: "bad_pattern_syntax",
202 severity: "error",
203 message: `Pattern \"${rule.pattern}\" looks malformed (unbalanced brackets or whitespace).`,
204 pattern: rule.pattern,
205 });
206 }
207
208 // b) Duplicate pattern.
209 if (seenPatterns.has(rule.pattern)) {
210 findings.push({
211 line: rule.line,
212 code: "duplicate_pattern",
213 severity: "warning",
214 message: `Pattern \"${rule.pattern}\" was already declared on line ${seenPatterns.get(
215 rule.pattern
216 )}. The later rule wins.`,
217 pattern: rule.pattern,
218 });
219 } else {
220 seenPatterns.set(rule.pattern, rule.line);
221 }
222
223 // c) Per-owner checks.
224 const seenOwners = new Set<string>();
225 // Re-parse the raw line to learn which owner tokens had an `@`.
226 const rawOwners = rule.raw
227 .replace(/#.*$/, "")
228 .trim()
229 .split(/\s+/)
230 .slice(1);
231 for (let i = 0; i < rule.owners.length; i++) {
232 const owner = rule.owners[i];
233 const rawToken = rawOwners[i] ?? owner;
234 const hadAt = rawToken.startsWith("@");
235 if (seenOwners.has(owner)) {
236 findings.push({
237 line: rule.line,
238 code: "duplicate_owner",
239 severity: "warning",
240 message: `Owner \"${owner}\" listed more than once on this rule.`,
241 pattern: rule.pattern,
242 token: owner,
243 });
244 continue;
245 }
246 seenOwners.add(owner);
247 const kind = classifyOwnerToken(owner, hadAt);
248 if (kind === "invalid") {
249 findings.push({
250 line: rule.line,
251 code: "bad_owner_format",
252 severity: "error",
253 message: `Owner \"${rawToken}\" isn't a valid @user, @org/team, or email.`,
254 pattern: rule.pattern,
255 token: rawToken,
256 });
257 continue;
258 }
259 if (kind === "user") {
260 const ok = await resolver.isUser(owner);
261 if (!ok) {
262 findings.push({
263 line: rule.line,
264 code: "unknown_user",
265 severity: "error",
266 message: `User @${owner} does not exist on this instance.`,
267 pattern: rule.pattern,
268 token: owner,
269 });
270 }
271 } else if (kind === "team") {
272 const [orgSlug, teamSlug] = owner.split("/");
273 const ok = await resolver.isTeam(orgSlug, teamSlug);
274 if (!ok) {
275 findings.push({
276 line: rule.line,
277 code: "unknown_team",
278 severity: "error",
279 message: `Team @${owner} does not exist on this instance.`,
280 pattern: rule.pattern,
281 token: owner,
282 });
283 }
284 }
285 // `email` is accepted as-is — we have no way to reach out and verify.
286 }
287 }
288
289 // 4. Missing catch-all `*` rule. Informational only.
290 const hasCatchAll = lex.rules.some(
291 (r) => r.pattern === "*" || r.pattern === "/*"
292 );
293 if (!hasCatchAll && lex.rules.length > 0) {
294 findings.push({
295 line: 1,
296 code: "missing_catchall",
297 severity: "info",
298 message:
299 "No catch-all rule (`* @owner`). Files outside any pattern will have no reviewer auto-assigned.",
300 });
301 }
302
303 return assembleReport(lex.rules, findings, lex.totalLines);
304}
305
306function assembleReport(
307 rules: LexedRule[],
308 findings: LintFinding[],
309 totalLines: number
310): LintReport {
311 // Sort findings by line, then severity (errors first).
312 const severityRank: Record<LintSeverity, number> = {
313 error: 0,
314 warning: 1,
315 info: 2,
316 };
317 findings.sort((a, b) => {
318 if (a.line !== b.line) return a.line - b.line;
319 return severityRank[a.severity] - severityRank[b.severity];
320 });
321 const errors = findings.filter((f) => f.severity === "error");
322 const warnings = findings.filter((f) => f.severity === "warning");
323 const infos = findings.filter((f) => f.severity === "info");
324 return {
325 totalLines,
326 totalRules: rules.length,
327 rules,
328 findings,
329 errors,
330 warnings,
331 infos,
332 ok: errors.length === 0,
333 };
334}
335
336export const __internal = {
337 EMAIL_RE,
338 USER_RE,
339 TEAM_RE,
340 lexCodeowners,
341 classifyOwnerToken,
342 isPlausiblePattern,
343 validateCodeowners,
344 assembleReport,
345};