@birdcc/linter 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.oxfmtrc.json +16 -0
  2. package/LICENSE +674 -0
  3. package/README.md +210 -0
  4. package/dist/index.d.ts +21 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +93 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/rules/bgp.d.ts +5 -0
  9. package/dist/rules/bgp.d.ts.map +1 -0
  10. package/dist/rules/bgp.js +131 -0
  11. package/dist/rules/bgp.js.map +1 -0
  12. package/dist/rules/catalog.d.ts +14 -0
  13. package/dist/rules/catalog.d.ts.map +1 -0
  14. package/dist/rules/catalog.js +61 -0
  15. package/dist/rules/catalog.js.map +1 -0
  16. package/dist/rules/cfg.d.ts +5 -0
  17. package/dist/rules/cfg.d.ts.map +1 -0
  18. package/dist/rules/cfg.js +264 -0
  19. package/dist/rules/cfg.js.map +1 -0
  20. package/dist/rules/net.d.ts +5 -0
  21. package/dist/rules/net.d.ts.map +1 -0
  22. package/dist/rules/net.js +140 -0
  23. package/dist/rules/net.js.map +1 -0
  24. package/dist/rules/normalize.d.ts +6 -0
  25. package/dist/rules/normalize.d.ts.map +1 -0
  26. package/dist/rules/normalize.js +65 -0
  27. package/dist/rules/normalize.js.map +1 -0
  28. package/dist/rules/ospf.d.ts +5 -0
  29. package/dist/rules/ospf.d.ts.map +1 -0
  30. package/dist/rules/ospf.js +136 -0
  31. package/dist/rules/ospf.js.map +1 -0
  32. package/dist/rules/shared.d.ts +46 -0
  33. package/dist/rules/shared.d.ts.map +1 -0
  34. package/dist/rules/shared.js +184 -0
  35. package/dist/rules/shared.js.map +1 -0
  36. package/dist/rules/sym.d.ts +5 -0
  37. package/dist/rules/sym.d.ts.map +1 -0
  38. package/dist/rules/sym.js +188 -0
  39. package/dist/rules/sym.js.map +1 -0
  40. package/dist/rules/type.d.ts +5 -0
  41. package/dist/rules/type.d.ts.map +1 -0
  42. package/dist/rules/type.js +130 -0
  43. package/dist/rules/type.js.map +1 -0
  44. package/package.json +41 -0
  45. package/src/index.ts +155 -0
  46. package/src/rules/bgp.ts +239 -0
  47. package/src/rules/catalog.ts +80 -0
  48. package/src/rules/cfg.ts +562 -0
  49. package/src/rules/net.ts +262 -0
  50. package/src/rules/normalize.ts +90 -0
  51. package/src/rules/ospf.ts +221 -0
  52. package/src/rules/shared.ts +354 -0
  53. package/src/rules/sym.ts +342 -0
  54. package/src/rules/type.ts +210 -0
  55. package/test/linter.bgp-ospf.test.ts +129 -0
  56. package/test/linter.migration.test.ts +66 -0
  57. package/test/linter.net-type.test.ts +132 -0
  58. package/test/linter.sym-cfg.test.ts +224 -0
  59. package/test/linter.test.ts +21 -0
  60. package/tsconfig.json +8 -0
@@ -0,0 +1,354 @@
1
+ import { isIP } from "node:net";
2
+ import type {
3
+ BirdDiagnostic,
4
+ BirdDiagnosticSeverity,
5
+ CoreSnapshot,
6
+ } from "@birdcc/core";
7
+ import type {
8
+ BirdDeclaration,
9
+ FilterBodyStatement,
10
+ FilterDeclaration,
11
+ FunctionDeclaration,
12
+ ParsedBirdDocument,
13
+ ProtocolDeclaration,
14
+ ProtocolStatement,
15
+ SourceRange,
16
+ TableDeclaration,
17
+ TemplateDeclaration,
18
+ } from "@birdcc/parser";
19
+ import { RULE_SEVERITY, type RuleCode } from "./catalog.js";
20
+
21
+ export interface RuleContext {
22
+ text: string;
23
+ parsed: ParsedBirdDocument;
24
+ core: CoreSnapshot;
25
+ }
26
+
27
+ export type BirdRule = (context: RuleContext) => BirdDiagnostic[];
28
+
29
+ export const normalizeClause = (text: string): string =>
30
+ text.trim().replace(/\s+/g, " ").toLowerCase();
31
+
32
+ export const createRuleDiagnostic = (
33
+ code: RuleCode,
34
+ message: string,
35
+ range: SourceRange,
36
+ source: BirdDiagnostic["source"] = "linter",
37
+ ): BirdDiagnostic => ({
38
+ code,
39
+ message,
40
+ severity: RULE_SEVERITY[code],
41
+ source,
42
+ range: {
43
+ line: range.line,
44
+ column: range.column,
45
+ endLine: range.endLine,
46
+ endColumn: range.endColumn,
47
+ },
48
+ });
49
+
50
+ export const diagnosticDedupKey = (diagnostic: BirdDiagnostic): string =>
51
+ [
52
+ diagnostic.code,
53
+ diagnostic.message,
54
+ diagnostic.range.line,
55
+ diagnostic.range.column,
56
+ diagnostic.range.endLine,
57
+ diagnostic.range.endColumn,
58
+ ].join(":");
59
+
60
+ export const pushUniqueDiagnostic = (
61
+ diagnostics: BirdDiagnostic[],
62
+ seen: Set<string>,
63
+ diagnostic: BirdDiagnostic,
64
+ ): void => {
65
+ const key = diagnosticDedupKey(diagnostic);
66
+ if (seen.has(key)) {
67
+ return;
68
+ }
69
+
70
+ seen.add(key);
71
+ diagnostics.push(diagnostic);
72
+ };
73
+
74
+ export const withSeverity = (
75
+ code: RuleCode,
76
+ diagnostic: Omit<BirdDiagnostic, "severity" | "code">,
77
+ ): BirdDiagnostic => ({
78
+ ...diagnostic,
79
+ code,
80
+ severity: RULE_SEVERITY[code],
81
+ });
82
+
83
+ export const protocolDeclarations = (
84
+ parsed: ParsedBirdDocument,
85
+ ): ProtocolDeclaration[] =>
86
+ parsed.program.declarations.filter(
87
+ (declaration): declaration is ProtocolDeclaration =>
88
+ declaration.kind === "protocol",
89
+ );
90
+
91
+ export const templateDeclarations = (
92
+ parsed: ParsedBirdDocument,
93
+ ): TemplateDeclaration[] =>
94
+ parsed.program.declarations.filter(
95
+ (declaration): declaration is TemplateDeclaration =>
96
+ declaration.kind === "template",
97
+ );
98
+
99
+ export const tableDeclarations = (
100
+ parsed: ParsedBirdDocument,
101
+ ): TableDeclaration[] =>
102
+ parsed.program.declarations.filter(
103
+ (declaration): declaration is TableDeclaration =>
104
+ declaration.kind === "table",
105
+ );
106
+
107
+ export const filterDeclarations = (
108
+ parsed: ParsedBirdDocument,
109
+ ): FilterDeclaration[] =>
110
+ parsed.program.declarations.filter(
111
+ (declaration): declaration is FilterDeclaration =>
112
+ declaration.kind === "filter",
113
+ );
114
+
115
+ export const functionDeclarations = (
116
+ parsed: ParsedBirdDocument,
117
+ ): FunctionDeclaration[] =>
118
+ parsed.program.declarations.filter(
119
+ (declaration): declaration is FunctionDeclaration =>
120
+ declaration.kind === "function",
121
+ );
122
+
123
+ export const filterAndFunctionDeclarations = (
124
+ parsed: ParsedBirdDocument,
125
+ ): Array<FilterDeclaration | FunctionDeclaration> =>
126
+ parsed.program.declarations.filter(
127
+ (declaration): declaration is FilterDeclaration | FunctionDeclaration =>
128
+ declaration.kind === "filter" || declaration.kind === "function",
129
+ );
130
+
131
+ export const routerIdDeclarations = (
132
+ parsed: ParsedBirdDocument,
133
+ ): BirdDeclaration[] =>
134
+ parsed.program.declarations.filter(
135
+ (declaration) => declaration.kind === "router-id",
136
+ );
137
+
138
+ export const isProtocolType = (
139
+ declaration: ProtocolDeclaration,
140
+ expected: string,
141
+ ): boolean => declaration.protocolType.toLowerCase() === expected;
142
+
143
+ export const protocolOtherStatements = (
144
+ declaration: ProtocolDeclaration,
145
+ ): ProtocolStatement[] =>
146
+ declaration.statements.filter((statement) => statement.kind === "other");
147
+
148
+ export const protocolOtherTextEntries = (
149
+ declaration: ProtocolDeclaration,
150
+ ): Array<{ text: string; range: SourceRange }> => {
151
+ const entries: Array<{ text: string; range: SourceRange }> = [];
152
+
153
+ for (const statement of declaration.statements) {
154
+ if (statement.kind === "other") {
155
+ entries.push({ text: statement.text, range: statement });
156
+ continue;
157
+ }
158
+
159
+ if (statement.kind !== "channel") {
160
+ continue;
161
+ }
162
+
163
+ for (const entry of statement.entries) {
164
+ if (entry.kind !== "other") {
165
+ continue;
166
+ }
167
+
168
+ entries.push({ text: entry.text, range: entry });
169
+ }
170
+ }
171
+
172
+ return entries;
173
+ };
174
+
175
+ export const channelOtherEntries = (
176
+ declaration: ProtocolDeclaration,
177
+ ): Array<{ channelType: string; text: string; range: SourceRange }> => {
178
+ const entries: Array<{
179
+ channelType: string;
180
+ text: string;
181
+ range: SourceRange;
182
+ }> = [];
183
+
184
+ for (const statement of declaration.statements) {
185
+ if (statement.kind !== "channel") {
186
+ continue;
187
+ }
188
+
189
+ for (const entry of statement.entries) {
190
+ if (entry.kind !== "other") {
191
+ continue;
192
+ }
193
+
194
+ entries.push({
195
+ channelType: statement.channelType,
196
+ text: entry.text,
197
+ range: entry,
198
+ });
199
+ }
200
+ }
201
+
202
+ return entries;
203
+ };
204
+
205
+ export const numericValue = (value: string | undefined): number | null => {
206
+ if (!value) {
207
+ return null;
208
+ }
209
+
210
+ const normalized = value.trim();
211
+ if (!/^-?\d+$/.test(normalized)) {
212
+ return null;
213
+ }
214
+
215
+ const parsed = Number.parseInt(normalized, 10);
216
+ return Number.isNaN(parsed) ? null : parsed;
217
+ };
218
+
219
+ export const extractFirstNumberAfterKeyword = (
220
+ text: string,
221
+ keyword: string,
222
+ ): number | null => {
223
+ const pattern = new RegExp(`${keyword}\\s+(-?\\d+)`, "i");
224
+ const matched = text.match(pattern);
225
+ return numericValue(matched?.[1]);
226
+ };
227
+
228
+ export const hasBooleanValue = (value: string): boolean =>
229
+ ["on", "off", "yes", "no", "true", "false", "enabled", "disabled"].includes(
230
+ normalizeClause(value),
231
+ );
232
+
233
+ export const extractFunctionCalls = (text: string): string[] => {
234
+ const names: string[] = [];
235
+ const pattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
236
+ const ignored = new Set(["if", "switch", "print", "defined"]);
237
+ let current = pattern.exec(text);
238
+
239
+ while (current) {
240
+ const name = current[1] ?? "";
241
+ const matchIndex = current.index ?? -1;
242
+ const isMethodCall = matchIndex > 0 && text[matchIndex - 1] === ".";
243
+ if (name.length > 0 && !ignored.has(name.toLowerCase()) && !isMethodCall) {
244
+ names.push(name);
245
+ }
246
+ current = pattern.exec(text);
247
+ }
248
+
249
+ return names;
250
+ };
251
+
252
+ export const scalarTypeOfExpression = (
253
+ expressionText: string,
254
+ ): "int" | "bool" | "string" | "ip" | "prefix" | "unknown" => {
255
+ const text = expressionText.trim();
256
+ if (/^-?\d+$/.test(text)) {
257
+ return "int";
258
+ }
259
+ if (/^(true|false|yes|no|on|off)$/i.test(text)) {
260
+ return "bool";
261
+ }
262
+ if (
263
+ (text.startsWith('"') && text.endsWith('"')) ||
264
+ (text.startsWith("'") && text.endsWith("'"))
265
+ ) {
266
+ return "string";
267
+ }
268
+ if (text.includes("/") && isPrefixLiteral(text)) {
269
+ return "prefix";
270
+ }
271
+ if (isIP(text) !== 0) {
272
+ return "ip";
273
+ }
274
+ return "unknown";
275
+ };
276
+
277
+ export const isPrefixLiteral = (value: string): boolean => {
278
+ const [address, lengthText] = value.split("/");
279
+ if (!address || !lengthText) {
280
+ return false;
281
+ }
282
+
283
+ const length = numericValue(lengthText);
284
+ if (length === null || length < 0 || length > 128) {
285
+ return false;
286
+ }
287
+
288
+ const family = isIP(address.trim());
289
+ if (family === 4) {
290
+ return length <= 32;
291
+ }
292
+
293
+ if (family === 6) {
294
+ return length <= 128;
295
+ }
296
+
297
+ return false;
298
+ };
299
+
300
+ export const eachFilterBodyExpression = (
301
+ parsed: ParsedBirdDocument,
302
+ ): Array<{ statement: FilterBodyStatement; declarationName: string }> => {
303
+ const list: Array<{
304
+ statement: FilterBodyStatement;
305
+ declarationName: string;
306
+ }> = [];
307
+
308
+ for (const declaration of filterAndFunctionDeclarations(parsed)) {
309
+ for (const statement of declaration.statements) {
310
+ list.push({ statement, declarationName: declaration.name });
311
+ }
312
+ }
313
+
314
+ return list;
315
+ };
316
+
317
+ export const findTemplateByName = (
318
+ parsed: ParsedBirdDocument,
319
+ templateName: string,
320
+ ): TemplateDeclaration | undefined => {
321
+ const lowered = templateName.toLowerCase();
322
+ return templateDeclarations(parsed).find(
323
+ (item) => item.name.toLowerCase() === lowered,
324
+ );
325
+ };
326
+
327
+ export const hasSymbolKind = (
328
+ core: CoreSnapshot,
329
+ kind: "protocol" | "template" | "filter" | "function" | "table",
330
+ name: string,
331
+ ): boolean => {
332
+ const lowered = name.toLowerCase();
333
+ return core.symbols.some(
334
+ (symbol) => symbol.kind === kind && symbol.name.toLowerCase() === lowered,
335
+ );
336
+ };
337
+
338
+ export const createProtocolDiagnostic = (
339
+ code: RuleCode,
340
+ message: string,
341
+ declaration: ProtocolDeclaration,
342
+ severity?: BirdDiagnosticSeverity,
343
+ ): BirdDiagnostic => ({
344
+ code,
345
+ message,
346
+ severity: severity ?? RULE_SEVERITY[code],
347
+ source: "linter",
348
+ range: {
349
+ line: declaration.nameRange.line,
350
+ column: declaration.nameRange.column,
351
+ endLine: declaration.nameRange.endLine,
352
+ endColumn: declaration.nameRange.endColumn,
353
+ },
354
+ });
@@ -0,0 +1,342 @@
1
+ import type { BirdDiagnostic } from "@birdcc/core";
2
+ import type {
3
+ ChannelEntry,
4
+ ChannelExportEntry,
5
+ ChannelImportEntry,
6
+ ProtocolDeclaration,
7
+ SourceRange,
8
+ } from "@birdcc/parser";
9
+ import {
10
+ createRuleDiagnostic,
11
+ eachFilterBodyExpression,
12
+ extractFunctionCalls,
13
+ filterDeclarations,
14
+ findTemplateByName,
15
+ functionDeclarations,
16
+ hasSymbolKind,
17
+ normalizeClause,
18
+ pushUniqueDiagnostic,
19
+ protocolDeclarations,
20
+ tableDeclarations,
21
+ type BirdRule,
22
+ } from "./shared.js";
23
+
24
+ const normalizeProtocolFamily = (text: string): string =>
25
+ normalizeClause(text).split(" ")[0] ?? "";
26
+ const BUILTIN_FILTER_NAMES = new Set([
27
+ "all",
28
+ "none",
29
+ "accept",
30
+ "reject",
31
+ "announce",
32
+ ]);
33
+ const BUILTIN_FUNCTION_NAMES = new Set([
34
+ "net",
35
+ "bgp_path",
36
+ "bgp_community",
37
+ "bgp_ext_community",
38
+ "bgp_large_community",
39
+ "bgp_origin",
40
+ "defined",
41
+ "len",
42
+ "match",
43
+ "route_source",
44
+ "roa_check",
45
+ "prefix",
46
+ "ip",
47
+ "int",
48
+ ]);
49
+
50
+ const isImportOrExportFilterClause = (
51
+ value: unknown,
52
+ ): value is {
53
+ mode: "filter";
54
+ filterName?: string;
55
+ filterNameRange?: SourceRange;
56
+ } => {
57
+ if (!value || typeof value !== "object") {
58
+ return false;
59
+ }
60
+
61
+ return (value as { mode?: string }).mode === "filter";
62
+ };
63
+
64
+ const isChannelFilterClause = (
65
+ entry: ChannelEntry,
66
+ ): entry is ChannelImportEntry | ChannelExportEntry =>
67
+ (entry.kind === "import" || entry.kind === "export") &&
68
+ entry.mode === "filter";
69
+
70
+ const collectProtocolFilterClauses = (
71
+ declaration: ProtocolDeclaration,
72
+ ): Array<{ filterName?: string; range: SourceRange }> => {
73
+ const clauses: Array<{ filterName?: string; range: SourceRange }> = [];
74
+
75
+ for (const statement of declaration.statements) {
76
+ if (isImportOrExportFilterClause(statement)) {
77
+ clauses.push({
78
+ filterName: statement.filterName,
79
+ range: statement.filterNameRange ?? statement,
80
+ });
81
+ continue;
82
+ }
83
+
84
+ if (statement.kind !== "channel") {
85
+ continue;
86
+ }
87
+
88
+ for (const entry of statement.entries) {
89
+ if (!isChannelFilterClause(entry)) {
90
+ continue;
91
+ }
92
+
93
+ clauses.push({
94
+ filterName: entry.filterName,
95
+ range: entry.filterNameRange ?? entry,
96
+ });
97
+ }
98
+ }
99
+
100
+ return clauses;
101
+ };
102
+
103
+ const symProtoTypeMismatchRule: BirdRule = ({ parsed }) => {
104
+ const diagnostics: BirdDiagnostic[] = [];
105
+ const seen = new Set<string>();
106
+
107
+ for (const declaration of protocolDeclarations(parsed)) {
108
+ if (!declaration.fromTemplate) {
109
+ continue;
110
+ }
111
+
112
+ const template = findTemplateByName(parsed, declaration.fromTemplate);
113
+ if (!template) {
114
+ continue;
115
+ }
116
+
117
+ const protocolFamily = normalizeProtocolFamily(declaration.protocolType);
118
+ const templateFamily = normalizeProtocolFamily(template.templateType);
119
+ if (
120
+ protocolFamily.length === 0 ||
121
+ templateFamily.length === 0 ||
122
+ protocolFamily === templateFamily
123
+ ) {
124
+ continue;
125
+ }
126
+
127
+ pushUniqueDiagnostic(
128
+ diagnostics,
129
+ seen,
130
+ createRuleDiagnostic(
131
+ "sym/proto-type-mismatch",
132
+ `Protocol '${declaration.name}' (${declaration.protocolType}) cannot use template '${template.name}' (${template.templateType})`,
133
+ declaration.fromTemplateRange ?? declaration.nameRange,
134
+ ),
135
+ );
136
+ }
137
+
138
+ return diagnostics;
139
+ };
140
+
141
+ const symFilterRequiredRule: BirdRule = ({ parsed, core }) => {
142
+ const diagnostics: BirdDiagnostic[] = [];
143
+ const seen = new Set<string>();
144
+ const filterNames = new Set(
145
+ filterDeclarations(parsed).map((item) => item.name.toLowerCase()),
146
+ );
147
+
148
+ for (const declaration of protocolDeclarations(parsed)) {
149
+ for (const clause of collectProtocolFilterClauses(declaration)) {
150
+ const name = clause.filterName?.trim();
151
+ if (!name) {
152
+ pushUniqueDiagnostic(
153
+ diagnostics,
154
+ seen,
155
+ createRuleDiagnostic(
156
+ "sym/filter-required",
157
+ `Protocol '${declaration.name}' requires a filter name in import/export filter clause`,
158
+ clause.range,
159
+ ),
160
+ );
161
+ continue;
162
+ }
163
+
164
+ const loweredName = name.toLowerCase();
165
+ if (
166
+ !filterNames.has(loweredName) &&
167
+ !hasSymbolKind(core, "filter", name)
168
+ ) {
169
+ if (BUILTIN_FILTER_NAMES.has(loweredName)) {
170
+ continue;
171
+ }
172
+
173
+ pushUniqueDiagnostic(
174
+ diagnostics,
175
+ seen,
176
+ createRuleDiagnostic(
177
+ "sym/filter-required",
178
+ `Protocol '${declaration.name}' references unknown filter '${name}'`,
179
+ clause.range,
180
+ ),
181
+ );
182
+ }
183
+ }
184
+ }
185
+
186
+ return diagnostics;
187
+ };
188
+
189
+ const symFunctionRequiredRule: BirdRule = ({ parsed, core }) => {
190
+ const diagnostics: BirdDiagnostic[] = [];
191
+ const seen = new Set<string>();
192
+ const functions = new Set(
193
+ functionDeclarations(parsed).map((item) => item.name.toLowerCase()),
194
+ );
195
+ for (const builtin of BUILTIN_FUNCTION_NAMES) {
196
+ functions.add(builtin);
197
+ }
198
+
199
+ for (const { statement, declarationName } of eachFilterBodyExpression(
200
+ parsed,
201
+ )) {
202
+ const textParts: string[] = [];
203
+ if (statement.kind === "expression") {
204
+ textParts.push(statement.expressionText);
205
+ }
206
+ if (statement.kind === "other") {
207
+ textParts.push(statement.text);
208
+ }
209
+ if (statement.kind === "if" && statement.conditionText) {
210
+ textParts.push(statement.conditionText);
211
+ }
212
+
213
+ if (textParts.length === 0) {
214
+ continue;
215
+ }
216
+
217
+ const joined = textParts.join(" ");
218
+ for (const callName of extractFunctionCalls(joined)) {
219
+ if (
220
+ functions.has(callName.toLowerCase()) ||
221
+ hasSymbolKind(core, "function", callName)
222
+ ) {
223
+ continue;
224
+ }
225
+
226
+ pushUniqueDiagnostic(
227
+ diagnostics,
228
+ seen,
229
+ createRuleDiagnostic(
230
+ "sym/function-required",
231
+ `Declaration '${declarationName}' references undefined function '${callName}'`,
232
+ statement,
233
+ ),
234
+ );
235
+ }
236
+ }
237
+
238
+ return diagnostics;
239
+ };
240
+
241
+ const symTableRequiredRule: BirdRule = ({ parsed, core }) => {
242
+ const diagnostics: BirdDiagnostic[] = [];
243
+ const seen = new Set<string>();
244
+ const tableNames = new Set(
245
+ tableDeclarations(parsed).map((item) => item.name.toLowerCase()),
246
+ );
247
+
248
+ for (const declaration of protocolDeclarations(parsed)) {
249
+ for (const statement of declaration.statements) {
250
+ if (statement.kind === "channel") {
251
+ for (const entry of statement.entries) {
252
+ if (entry.kind !== "table") {
253
+ continue;
254
+ }
255
+
256
+ const tableName = entry.tableName.trim();
257
+ if (!tableName) {
258
+ pushUniqueDiagnostic(
259
+ diagnostics,
260
+ seen,
261
+ createRuleDiagnostic(
262
+ "sym/table-required",
263
+ `Protocol '${declaration.name}' requires a table name in channel table clause`,
264
+ entry,
265
+ ),
266
+ );
267
+ continue;
268
+ }
269
+
270
+ if (
271
+ !tableNames.has(tableName.toLowerCase()) &&
272
+ !hasSymbolKind(core, "table", tableName)
273
+ ) {
274
+ pushUniqueDiagnostic(
275
+ diagnostics,
276
+ seen,
277
+ createRuleDiagnostic(
278
+ "sym/table-required",
279
+ `Protocol '${declaration.name}' references unknown table '${tableName}'`,
280
+ entry.tableNameRange,
281
+ ),
282
+ );
283
+ }
284
+ }
285
+ }
286
+
287
+ if (statement.kind !== "other") {
288
+ continue;
289
+ }
290
+
291
+ const text = statement.text;
292
+ const matched = text.match(/\btable\b(?:\s+([A-Za-z_][A-Za-z0-9_]*))?/i);
293
+ if (!matched) {
294
+ continue;
295
+ }
296
+
297
+ const tableName = matched[1]?.trim();
298
+ if (!tableName) {
299
+ pushUniqueDiagnostic(
300
+ diagnostics,
301
+ seen,
302
+ createRuleDiagnostic(
303
+ "sym/table-required",
304
+ `Protocol '${declaration.name}' requires table name after 'table'`,
305
+ statement,
306
+ ),
307
+ );
308
+ continue;
309
+ }
310
+
311
+ if (
312
+ !tableNames.has(tableName.toLowerCase()) &&
313
+ !hasSymbolKind(core, "table", tableName)
314
+ ) {
315
+ pushUniqueDiagnostic(
316
+ diagnostics,
317
+ seen,
318
+ createRuleDiagnostic(
319
+ "sym/table-required",
320
+ `Protocol '${declaration.name}' references unknown table '${tableName}'`,
321
+ statement,
322
+ ),
323
+ );
324
+ }
325
+ }
326
+ }
327
+
328
+ return diagnostics;
329
+ };
330
+
331
+ export const symRules: BirdRule[] = [
332
+ symProtoTypeMismatchRule,
333
+ symFilterRequiredRule,
334
+ symFunctionRequiredRule,
335
+ symTableRequiredRule,
336
+ ];
337
+
338
+ export const collectSymRuleDiagnostics = (
339
+ context: Parameters<BirdRule>[0],
340
+ ): BirdDiagnostic[] => {
341
+ return symRules.flatMap((rule) => rule(context));
342
+ };