@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,562 @@
1
+ import { isIP } from "node:net";
2
+ import type { BirdDiagnostic } from "@birdcc/core";
3
+ import type { SourceRange } from "@birdcc/parser";
4
+ import {
5
+ channelOtherEntries,
6
+ createProtocolDiagnostic,
7
+ createRuleDiagnostic,
8
+ extractFirstNumberAfterKeyword,
9
+ hasBooleanValue,
10
+ isProtocolType,
11
+ numericValue,
12
+ protocolDeclarations,
13
+ pushUniqueDiagnostic,
14
+ routerIdDeclarations,
15
+ templateDeclarations,
16
+ type BirdRule,
17
+ } from "./shared.js";
18
+
19
+ const MAX_ASN = 4_294_967_294;
20
+ const MAX_ROUTE_LIMIT = 10_000_000;
21
+
22
+ const cfgNoProtocolRule: BirdRule = ({ parsed }) => {
23
+ if (protocolDeclarations(parsed).length > 0) {
24
+ return [];
25
+ }
26
+
27
+ return [
28
+ createRuleDiagnostic(
29
+ "cfg/no-protocol",
30
+ "No protocol is specified in configuration",
31
+ {
32
+ line: 1,
33
+ column: 1,
34
+ endLine: 1,
35
+ endColumn: 1,
36
+ },
37
+ ),
38
+ ];
39
+ };
40
+
41
+ const cfgMissingRouterIdRule: BirdRule = ({ parsed }) => {
42
+ if (routerIdDeclarations(parsed).length > 0) {
43
+ return [];
44
+ }
45
+
46
+ return [
47
+ createRuleDiagnostic(
48
+ "cfg/missing-router-id",
49
+ "Router ID must be configured",
50
+ {
51
+ line: 1,
52
+ column: 1,
53
+ endLine: 1,
54
+ endColumn: 1,
55
+ },
56
+ ),
57
+ ];
58
+ };
59
+
60
+ const diagnoseNumberExpected = (
61
+ diagnostics: BirdDiagnostic[],
62
+ seen: Set<string>,
63
+ range: SourceRange,
64
+ declarationName: string,
65
+ fieldName: string,
66
+ value: string | undefined,
67
+ ): void => {
68
+ if (value === undefined) {
69
+ return;
70
+ }
71
+
72
+ if (numericValue(value) !== null) {
73
+ return;
74
+ }
75
+
76
+ pushUniqueDiagnostic(
77
+ diagnostics,
78
+ seen,
79
+ createRuleDiagnostic(
80
+ "cfg/number-expected",
81
+ `Protocol '${declarationName}' expects numeric value for '${fieldName}'`,
82
+ range,
83
+ ),
84
+ );
85
+ };
86
+
87
+ const diagnoseValueOutOfRange = (
88
+ diagnostics: BirdDiagnostic[],
89
+ seen: Set<string>,
90
+ range: SourceRange,
91
+ declarationName: string,
92
+ fieldName: string,
93
+ value: number | null,
94
+ min: number,
95
+ max: number,
96
+ ): void => {
97
+ if (value === null) {
98
+ return;
99
+ }
100
+
101
+ if (value >= min && value <= max) {
102
+ return;
103
+ }
104
+
105
+ pushUniqueDiagnostic(
106
+ diagnostics,
107
+ seen,
108
+ createRuleDiagnostic(
109
+ "cfg/value-out-of-range",
110
+ `Protocol '${declarationName}' has out-of-range value for '${fieldName}': ${value}`,
111
+ range,
112
+ ),
113
+ );
114
+ };
115
+
116
+ const cfgNumericRules: BirdRule = ({ parsed }) => {
117
+ const diagnostics: BirdDiagnostic[] = [];
118
+ const seen = new Set<string>();
119
+
120
+ for (const declaration of protocolDeclarations(parsed)) {
121
+ for (const statement of declaration.statements) {
122
+ if (statement.kind === "local-as") {
123
+ diagnoseNumberExpected(
124
+ diagnostics,
125
+ seen,
126
+ statement.asnRange,
127
+ declaration.name,
128
+ "local as",
129
+ statement.asn,
130
+ );
131
+ diagnoseValueOutOfRange(
132
+ diagnostics,
133
+ seen,
134
+ statement.asnRange,
135
+ declaration.name,
136
+ "local as",
137
+ numericValue(statement.asn),
138
+ 1,
139
+ MAX_ASN,
140
+ );
141
+ }
142
+
143
+ if (statement.kind === "neighbor" && statement.asnRange) {
144
+ diagnoseNumberExpected(
145
+ diagnostics,
146
+ seen,
147
+ statement.asnRange,
148
+ declaration.name,
149
+ "neighbor as",
150
+ statement.asn,
151
+ );
152
+ diagnoseValueOutOfRange(
153
+ diagnostics,
154
+ seen,
155
+ statement.asnRange,
156
+ declaration.name,
157
+ "neighbor as",
158
+ numericValue(statement.asn),
159
+ 1,
160
+ MAX_ASN,
161
+ );
162
+ }
163
+
164
+ if (statement.kind === "other") {
165
+ const lowerText = statement.text.toLowerCase();
166
+
167
+ const holdNumber = extractFirstNumberAfterKeyword(lowerText, "hold");
168
+ if (holdNumber === null && /\bhold\b/.test(lowerText)) {
169
+ diagnoseNumberExpected(
170
+ diagnostics,
171
+ seen,
172
+ statement,
173
+ declaration.name,
174
+ "hold",
175
+ "",
176
+ );
177
+ }
178
+ diagnoseValueOutOfRange(
179
+ diagnostics,
180
+ seen,
181
+ statement,
182
+ declaration.name,
183
+ "hold",
184
+ holdNumber,
185
+ 3,
186
+ 65_535,
187
+ );
188
+
189
+ const keepaliveNumber = extractFirstNumberAfterKeyword(
190
+ lowerText,
191
+ "keepalive",
192
+ );
193
+ if (keepaliveNumber === null && /\bkeepalive\b/.test(lowerText)) {
194
+ diagnoseNumberExpected(
195
+ diagnostics,
196
+ seen,
197
+ statement,
198
+ declaration.name,
199
+ "keepalive",
200
+ "",
201
+ );
202
+ }
203
+ diagnoseValueOutOfRange(
204
+ diagnostics,
205
+ seen,
206
+ statement,
207
+ declaration.name,
208
+ "keepalive",
209
+ keepaliveNumber,
210
+ 1,
211
+ 65_535,
212
+ );
213
+
214
+ const ttlNumber = extractFirstNumberAfterKeyword(lowerText, "ttl");
215
+ if (ttlNumber === null && /\bttl\b/.test(lowerText)) {
216
+ diagnoseNumberExpected(
217
+ diagnostics,
218
+ seen,
219
+ statement,
220
+ declaration.name,
221
+ "ttl",
222
+ "",
223
+ );
224
+ }
225
+ diagnoseValueOutOfRange(
226
+ diagnostics,
227
+ seen,
228
+ statement,
229
+ declaration.name,
230
+ "ttl",
231
+ ttlNumber,
232
+ 1,
233
+ 255,
234
+ );
235
+ }
236
+
237
+ if (statement.kind !== "channel") {
238
+ continue;
239
+ }
240
+
241
+ for (const entry of statement.entries) {
242
+ if (entry.kind === "limit") {
243
+ diagnoseNumberExpected(
244
+ diagnostics,
245
+ seen,
246
+ entry.valueRange,
247
+ declaration.name,
248
+ "limit",
249
+ entry.value,
250
+ );
251
+ diagnoseValueOutOfRange(
252
+ diagnostics,
253
+ seen,
254
+ entry.valueRange,
255
+ declaration.name,
256
+ "limit",
257
+ numericValue(entry.value),
258
+ 0,
259
+ MAX_ROUTE_LIMIT,
260
+ );
261
+ }
262
+
263
+ if (entry.kind !== "other") {
264
+ continue;
265
+ }
266
+
267
+ const maxPrefixMatch = entry.text.match(/\bmax\s+prefix\s+([^;\s]+)/i);
268
+ if (!maxPrefixMatch) {
269
+ continue;
270
+ }
271
+
272
+ const token = maxPrefixMatch[1] ?? "";
273
+ diagnoseNumberExpected(
274
+ diagnostics,
275
+ seen,
276
+ entry,
277
+ declaration.name,
278
+ "max prefix",
279
+ token,
280
+ );
281
+ diagnoseValueOutOfRange(
282
+ diagnostics,
283
+ seen,
284
+ entry,
285
+ declaration.name,
286
+ "max prefix",
287
+ numericValue(token),
288
+ 0,
289
+ MAX_ROUTE_LIMIT,
290
+ );
291
+ }
292
+ }
293
+ }
294
+
295
+ return diagnostics;
296
+ };
297
+
298
+ const cfgSwitchValueExpectedRule: BirdRule = ({ parsed }) => {
299
+ const diagnostics: BirdDiagnostic[] = [];
300
+ const seen = new Set<string>();
301
+ const switchPattern =
302
+ /\b(passive|stub|bfd|enabled|disabled|check\s+link|deterministic\s+med)\b\s+([^;\s]+)/i;
303
+
304
+ for (const declaration of protocolDeclarations(parsed)) {
305
+ for (const statement of declaration.statements) {
306
+ if (statement.kind === "other") {
307
+ const matched = statement.text.match(switchPattern);
308
+ if (matched) {
309
+ const token = matched[2] ?? "";
310
+ if (!hasBooleanValue(token)) {
311
+ pushUniqueDiagnostic(
312
+ diagnostics,
313
+ seen,
314
+ createRuleDiagnostic(
315
+ "cfg/switch-value-expected",
316
+ `Protocol '${declaration.name}' expects boolean value for '${matched[1]}'`,
317
+ statement,
318
+ ),
319
+ );
320
+ }
321
+ }
322
+ }
323
+
324
+ if (statement.kind !== "channel") {
325
+ continue;
326
+ }
327
+
328
+ for (const entry of statement.entries) {
329
+ if (entry.kind !== "keep-filtered") {
330
+ continue;
331
+ }
332
+
333
+ if (hasBooleanValue(entry.value)) {
334
+ continue;
335
+ }
336
+
337
+ pushUniqueDiagnostic(
338
+ diagnostics,
339
+ seen,
340
+ createRuleDiagnostic(
341
+ "cfg/switch-value-expected",
342
+ `Protocol '${declaration.name}' expects boolean value for keep filtered`,
343
+ entry.valueRange,
344
+ ),
345
+ );
346
+ }
347
+ }
348
+ }
349
+
350
+ return diagnostics;
351
+ };
352
+
353
+ const extractIps = (text: string): string[] => {
354
+ const parts = text
355
+ .split(/[^0-9A-Fa-f:./]+/)
356
+ .map((item) => item.trim())
357
+ .filter((item) => item.length > 0);
358
+
359
+ const values: string[] = [];
360
+ for (const part of parts) {
361
+ const candidate = part.includes("/") ? (part.split("/")[0] ?? "") : part;
362
+ if (isIP(candidate) !== 0) {
363
+ values.push(candidate);
364
+ }
365
+ }
366
+
367
+ return values;
368
+ };
369
+
370
+ const cfgIpNetworkMismatchRule: BirdRule = ({ parsed }) => {
371
+ const diagnostics: BirdDiagnostic[] = [];
372
+ const seen = new Set<string>();
373
+
374
+ for (const declaration of protocolDeclarations(parsed)) {
375
+ for (const entry of channelOtherEntries(declaration)) {
376
+ if (entry.channelType !== "ipv4" && entry.channelType !== "ipv6") {
377
+ continue;
378
+ }
379
+
380
+ for (const value of extractIps(entry.text)) {
381
+ const family = isIP(value);
382
+ if (
383
+ (entry.channelType === "ipv4" && family === 4) ||
384
+ (entry.channelType === "ipv6" && family === 6)
385
+ ) {
386
+ continue;
387
+ }
388
+
389
+ pushUniqueDiagnostic(
390
+ diagnostics,
391
+ seen,
392
+ createRuleDiagnostic(
393
+ "cfg/ip-network-mismatch",
394
+ `Protocol '${declaration.name}' channel '${entry.channelType}' contains mismatched address '${value}'`,
395
+ entry.range,
396
+ ),
397
+ );
398
+ }
399
+ }
400
+ }
401
+
402
+ return diagnostics;
403
+ };
404
+
405
+ const cfgIncompatibleTypeRule: BirdRule = ({ parsed }) => {
406
+ const diagnostics: BirdDiagnostic[] = [];
407
+ const seen = new Set<string>();
408
+
409
+ for (const declaration of protocolDeclarations(parsed)) {
410
+ for (const statement of declaration.statements) {
411
+ if (statement.kind === "neighbor" && statement.addressKind !== "ip") {
412
+ pushUniqueDiagnostic(
413
+ diagnostics,
414
+ seen,
415
+ createRuleDiagnostic(
416
+ "cfg/incompatible-type",
417
+ `Protocol '${declaration.name}' neighbor address must be an IP literal`,
418
+ statement.addressRange,
419
+ ),
420
+ );
421
+ }
422
+
423
+ if (
424
+ statement.kind === "other" &&
425
+ /\brouter\s+id\s+\S+/i.test(statement.text)
426
+ ) {
427
+ const matched = statement.text.match(/\brouter\s+id\s+([^;\s]+)/i);
428
+ const value = matched?.[1] ?? "";
429
+ if (value && isIP(value) !== 4) {
430
+ pushUniqueDiagnostic(
431
+ diagnostics,
432
+ seen,
433
+ createRuleDiagnostic(
434
+ "cfg/incompatible-type",
435
+ `Router id '${value}' must be IPv4`,
436
+ statement,
437
+ ),
438
+ );
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ return diagnostics;
445
+ };
446
+
447
+ const hasTemplateCycleFrom = (
448
+ start: string,
449
+ graph: Map<string, string>,
450
+ ): boolean => {
451
+ const path = new Set<string>();
452
+ let current = start;
453
+ let hops = 0;
454
+ const maxHops = graph.size + 1;
455
+
456
+ while (current.length > 0) {
457
+ if (path.has(current)) {
458
+ return true;
459
+ }
460
+
461
+ path.add(current);
462
+ current = graph.get(current) ?? "";
463
+ hops += 1;
464
+
465
+ if (hops > maxHops) {
466
+ return true;
467
+ }
468
+ }
469
+
470
+ return false;
471
+ };
472
+
473
+ const cfgCircularTemplateRule: BirdRule = ({ parsed }) => {
474
+ const diagnostics: BirdDiagnostic[] = [];
475
+ const seen = new Set<string>();
476
+ const templates = templateDeclarations(parsed);
477
+ if (templates.length === 0) {
478
+ return diagnostics;
479
+ }
480
+
481
+ const graph = new Map<string, string>();
482
+ const ranges = new Map<string, SourceRange>();
483
+
484
+ for (const template of templates) {
485
+ const name = template.name.toLowerCase();
486
+ ranges.set(name, template.nameRange);
487
+ if (template.fromTemplate) {
488
+ graph.set(name, template.fromTemplate.toLowerCase());
489
+ }
490
+ }
491
+
492
+ for (const template of templates) {
493
+ const name = template.name.toLowerCase();
494
+ if (!hasTemplateCycleFrom(name, graph)) {
495
+ continue;
496
+ }
497
+
498
+ const range = ranges.get(name) ?? template.nameRange;
499
+ pushUniqueDiagnostic(
500
+ diagnostics,
501
+ seen,
502
+ createRuleDiagnostic(
503
+ "cfg/circular-template",
504
+ `Template '${template.name}' is in a circular inheritance chain`,
505
+ range,
506
+ ),
507
+ );
508
+ }
509
+
510
+ return diagnostics;
511
+ };
512
+
513
+ const cfgProtocolSpecificHintRule: BirdRule = ({ parsed }) => {
514
+ const diagnostics: BirdDiagnostic[] = [];
515
+ const seen = new Set<string>();
516
+
517
+ for (const declaration of protocolDeclarations(parsed)) {
518
+ if (!isProtocolType(declaration, "bgp")) {
519
+ continue;
520
+ }
521
+
522
+ for (const statement of declaration.statements) {
523
+ if (statement.kind !== "other") {
524
+ continue;
525
+ }
526
+
527
+ if (!/\bneighbor\s+\S+\s+as\s+[^0-9\s;]+/i.test(statement.text)) {
528
+ continue;
529
+ }
530
+
531
+ pushUniqueDiagnostic(
532
+ diagnostics,
533
+ seen,
534
+ createProtocolDiagnostic(
535
+ "cfg/number-expected",
536
+ `Protocol '${declaration.name}' expects numeric ASN in neighbor statement`,
537
+ declaration,
538
+ "error",
539
+ ),
540
+ );
541
+ }
542
+ }
543
+
544
+ return diagnostics;
545
+ };
546
+
547
+ export const cfgRules: BirdRule[] = [
548
+ cfgNoProtocolRule,
549
+ cfgMissingRouterIdRule,
550
+ cfgNumericRules,
551
+ cfgSwitchValueExpectedRule,
552
+ cfgIpNetworkMismatchRule,
553
+ cfgIncompatibleTypeRule,
554
+ cfgCircularTemplateRule,
555
+ cfgProtocolSpecificHintRule,
556
+ ];
557
+
558
+ export const collectCfgRuleDiagnostics = (
559
+ context: Parameters<BirdRule>[0],
560
+ ): BirdDiagnostic[] => {
561
+ return cfgRules.flatMap((rule) => rule(context));
562
+ };