@birdcc/core 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 (58) hide show
  1. package/.oxfmtrc.json +16 -0
  2. package/LICENSE +674 -0
  3. package/README.md +343 -0
  4. package/dist/cross-file.d.ts +5 -0
  5. package/dist/cross-file.d.ts.map +1 -0
  6. package/dist/cross-file.js +264 -0
  7. package/dist/cross-file.js.map +1 -0
  8. package/dist/index.d.ts +10 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +12 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/prefix.d.ts +2 -0
  13. package/dist/prefix.d.ts.map +1 -0
  14. package/dist/prefix.js +76 -0
  15. package/dist/prefix.js.map +1 -0
  16. package/dist/range.d.ts +3 -0
  17. package/dist/range.d.ts.map +1 -0
  18. package/dist/range.js +7 -0
  19. package/dist/range.js.map +1 -0
  20. package/dist/semantic-diagnostics.d.ts +4 -0
  21. package/dist/semantic-diagnostics.d.ts.map +1 -0
  22. package/dist/semantic-diagnostics.js +75 -0
  23. package/dist/semantic-diagnostics.js.map +1 -0
  24. package/dist/snapshot.d.ts +7 -0
  25. package/dist/snapshot.d.ts.map +1 -0
  26. package/dist/snapshot.js +22 -0
  27. package/dist/snapshot.js.map +1 -0
  28. package/dist/symbol-table.d.ts +9 -0
  29. package/dist/symbol-table.d.ts.map +1 -0
  30. package/dist/symbol-table.js +118 -0
  31. package/dist/symbol-table.js.map +1 -0
  32. package/dist/template-cycles.d.ts +9 -0
  33. package/dist/template-cycles.d.ts.map +1 -0
  34. package/dist/template-cycles.js +95 -0
  35. package/dist/template-cycles.js.map +1 -0
  36. package/dist/type-checker.d.ts +4 -0
  37. package/dist/type-checker.d.ts.map +1 -0
  38. package/dist/type-checker.js +390 -0
  39. package/dist/type-checker.js.map +1 -0
  40. package/dist/types.d.ts +84 -0
  41. package/dist/types.d.ts.map +1 -0
  42. package/dist/types.js +2 -0
  43. package/dist/types.js.map +1 -0
  44. package/package.json +45 -0
  45. package/scripts/benchmark-node-vs-regex.js +86 -0
  46. package/src/cross-file.ts +412 -0
  47. package/src/index.ts +42 -0
  48. package/src/prefix.ts +94 -0
  49. package/src/range.ts +12 -0
  50. package/src/semantic-diagnostics.ts +93 -0
  51. package/src/snapshot.ts +32 -0
  52. package/src/symbol-table.ts +171 -0
  53. package/src/template-cycles.ts +142 -0
  54. package/src/type-checker.ts +595 -0
  55. package/src/types.ts +101 -0
  56. package/test/core.test.ts +503 -0
  57. package/test/prefix.test.ts +40 -0
  58. package/tsconfig.json +8 -0
@@ -0,0 +1,503 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildCoreSnapshot,
4
+ buildCoreSnapshotFromParsed,
5
+ checkTypes,
6
+ resolveCrossFileReferences,
7
+ } from "../src/index.js";
8
+ import { parseBirdConfig } from "@birdcc/parser";
9
+
10
+ describe("@birdcc/core boundaries", () => {
11
+ it("reports duplicate symbol definitions", async () => {
12
+ const sample = `
13
+ filter policy_a { accept; }
14
+ filter policy_a { reject; }
15
+ `;
16
+
17
+ const result = await buildCoreSnapshot(sample);
18
+ expect(
19
+ result.diagnostics.some(
20
+ (item) =>
21
+ item.code === "semantic/duplicate-definition" &&
22
+ item.severity === "error",
23
+ ),
24
+ ).toBe(true);
25
+ });
26
+
27
+ it("reports undefined template reference", async () => {
28
+ const sample = `
29
+ protocol bgp edge from missing_template {
30
+ }
31
+ `;
32
+
33
+ const result = await buildCoreSnapshot(sample);
34
+ expect(
35
+ result.diagnostics.some(
36
+ (item) => item.code === "semantic/undefined-reference",
37
+ ),
38
+ ).toBe(true);
39
+ });
40
+
41
+ it("reports invalid router id", async () => {
42
+ const sample = `
43
+ router id 999.0.0.1;
44
+ `;
45
+
46
+ const result = await buildCoreSnapshot(sample);
47
+ expect(
48
+ result.diagnostics.some(
49
+ (item) => item.code === "semantic/invalid-router-id",
50
+ ),
51
+ ).toBe(true);
52
+ });
53
+
54
+ it("reports invalid neighbor address", async () => {
55
+ const sample = `
56
+ protocol bgp edge {
57
+ local as 65001;
58
+ neighbor 203.0.113.999 as 65002;
59
+ }
60
+ `;
61
+
62
+ const result = await buildCoreSnapshot(sample);
63
+ expect(
64
+ result.diagnostics.some(
65
+ (item) => item.code === "semantic/invalid-neighbor-address",
66
+ ),
67
+ ).toBe(true);
68
+ });
69
+
70
+ it("reports invalid CIDR literal", async () => {
71
+ const sample = `
72
+ filter export_policy {
73
+ if net ~ [ 2001:db8::/200 ] then reject;
74
+ accept;
75
+ }
76
+ `;
77
+
78
+ const result = await buildCoreSnapshot(sample);
79
+ expect(
80
+ result.diagnostics.some((item) => item.code === "semantic/invalid-cidr"),
81
+ ).toBe(true);
82
+ });
83
+
84
+ it("passes valid router and prefix literals", async () => {
85
+ const sample = `
86
+ router id 192.0.2.1;
87
+
88
+ template bgp edge_tpl {
89
+ }
90
+
91
+ protocol bgp edge from edge_tpl {
92
+ local as 65001;
93
+ neighbor 192.0.2.1 as 65002;
94
+ ipv4 {
95
+ import where net.len <= 24;
96
+ };
97
+ }
98
+
99
+ filter export_policy {
100
+ if net ~ [ 10.0.0.0/8+, 2001:db8::/32{33,128} ] then reject;
101
+ accept;
102
+ }
103
+ `;
104
+
105
+ const result = await buildCoreSnapshot(sample);
106
+
107
+ const errorCodes = result.diagnostics
108
+ .filter((item) => item.severity === "error")
109
+ .map((item) => item.code);
110
+
111
+ expect(errorCodes).not.toContain("semantic/invalid-router-id");
112
+ expect(errorCodes).not.toContain("semantic/invalid-neighbor-address");
113
+ expect(errorCodes).not.toContain("semantic/invalid-cidr");
114
+ });
115
+
116
+ it("emits type diagnostics for assignment mismatch and undefined variable", async () => {
117
+ const sample = `
118
+ filter export_policy {
119
+ int limit = 42;
120
+ limit = "too-big";
121
+ unknown_var = 5;
122
+ accept;
123
+ }
124
+ `;
125
+
126
+ const parsed = await parseBirdConfig(sample);
127
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
128
+
129
+ expect(
130
+ snapshot.typeDiagnostics.some((item) => item.code === "type/mismatch"),
131
+ ).toBe(true);
132
+ expect(
133
+ snapshot.typeDiagnostics.some(
134
+ (item) => item.code === "type/undefined-variable",
135
+ ),
136
+ ).toBe(true);
137
+ });
138
+
139
+ it("checkTypes works with explicit program + symbolTable input", async () => {
140
+ const sample = `
141
+ function calc() -> int {
142
+ int value = 1;
143
+ value = 2;
144
+ return value;
145
+ }
146
+ `;
147
+
148
+ const parsed = await parseBirdConfig(sample);
149
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
150
+ const diagnostics = checkTypes(parsed.program, snapshot.symbolTable);
151
+
152
+ expect(diagnostics).toHaveLength(0);
153
+ });
154
+
155
+ it("infers expression types for arithmetic comparison and boolean logic", async () => {
156
+ const sample = `
157
+ filter export_policy {
158
+ int threshold = 24;
159
+ bool in_range = (threshold + 1) >= 20;
160
+ bool ok = in_range && true;
161
+ bool precedence_ok = 1 < 2 == true;
162
+ bool mismatch = threshold + 1;
163
+ int wrong = in_range;
164
+ accept;
165
+ }
166
+ `;
167
+
168
+ const parsed = await parseBirdConfig(sample);
169
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
170
+ const mismatchDiagnostics = snapshot.typeDiagnostics.filter(
171
+ (item) => item.code === "type/mismatch",
172
+ );
173
+
174
+ expect(mismatchDiagnostics).toHaveLength(2);
175
+ });
176
+
177
+ it("infers nested expressions and reports mismatch on incompatible assignment", async () => {
178
+ const sample = `
179
+ function calc() -> bool {
180
+ int value = 3;
181
+ bool ok = !(value < 1) || ((value + 2) > 4);
182
+ int mismatch = value > 1;
183
+ return ok;
184
+ }
185
+ `;
186
+
187
+ const parsed = await parseBirdConfig(sample);
188
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
189
+ const diagnostics = checkTypes(parsed.program, snapshot.symbolTable);
190
+ const mismatchDiagnostics = diagnostics.filter(
191
+ (item) => item.code === "type/mismatch",
192
+ );
193
+
194
+ expect(mismatchDiagnostics).toHaveLength(1);
195
+ });
196
+
197
+ it("infers match expressions against set literals as bool", async () => {
198
+ const sample = `
199
+ filter export_policy {
200
+ prefix target = 10.0.0.0/8;
201
+ bool matched = target ~ [ 10.0.0.0/8, 192.0.2.0/24 ];
202
+ bool not_matched = target !~ [ 203.0.113.0/24 ];
203
+ int mismatch = target ~ [ 10.0.0.0/8 ];
204
+ accept;
205
+ }
206
+ `;
207
+
208
+ const parsed = await parseBirdConfig(sample);
209
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
210
+ const mismatchDiagnostics = snapshot.typeDiagnostics.filter(
211
+ (item) => item.code === "type/mismatch",
212
+ );
213
+
214
+ expect(mismatchDiagnostics).toHaveLength(1);
215
+ expect(mismatchDiagnostics[0]?.message).toContain("expected int, got bool");
216
+ });
217
+
218
+ it("infers integer membership against int set literal", async () => {
219
+ const sample = `
220
+ function calc() -> bool {
221
+ int asn = 65001;
222
+ bool in_set = asn ~ [ 65000, 65001, 65002 ];
223
+ return in_set;
224
+ }
225
+ `;
226
+
227
+ const parsed = await parseBirdConfig(sample);
228
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
229
+
230
+ expect(snapshot.typeDiagnostics).toHaveLength(0);
231
+ });
232
+
233
+ it("does not infer non-set int membership as bool", async () => {
234
+ const sample = `
235
+ function calc() -> int {
236
+ int value = 65001 ~ 65002;
237
+ return value;
238
+ }
239
+ `;
240
+
241
+ const parsed = await parseBirdConfig(sample);
242
+ const snapshot = buildCoreSnapshotFromParsed(parsed);
243
+
244
+ expect(snapshot.typeDiagnostics).toHaveLength(0);
245
+ });
246
+
247
+ it("resolves include/template references across files", async () => {
248
+ const result = await resolveCrossFileReferences({
249
+ entryUri: "/workspace/main.conf",
250
+ documents: [
251
+ {
252
+ uri: "/workspace/main.conf",
253
+ text: `
254
+ include "templates/common.conf";
255
+ protocol bgp edge from edge_tpl {
256
+ }
257
+ `,
258
+ },
259
+ {
260
+ uri: "/workspace/templates/common.conf",
261
+ text: `
262
+ template bgp edge_tpl {
263
+ }
264
+ `,
265
+ },
266
+ ],
267
+ });
268
+
269
+ const undefinedTemplateDiagnostics = result.diagnostics.filter(
270
+ (item) => item.code === "semantic/undefined-reference",
271
+ );
272
+
273
+ expect(result.visitedUris).toContain("/workspace/main.conf");
274
+ expect(result.visitedUris).toContain("/workspace/templates/common.conf");
275
+ expect(undefinedTemplateDiagnostics).toHaveLength(0);
276
+ });
277
+
278
+ it("reports circular template inheritance in single document", async () => {
279
+ const sample = `
280
+ template bgp a from b {
281
+ }
282
+ template bgp b from c {
283
+ }
284
+ template bgp c from a {
285
+ }
286
+ `;
287
+
288
+ const result = await buildCoreSnapshot(sample);
289
+ expect(
290
+ result.diagnostics.some(
291
+ (item) => item.code === "semantic/circular-template",
292
+ ),
293
+ ).toBe(true);
294
+ });
295
+
296
+ it("reports circular template inheritance across includes", async () => {
297
+ const result = await resolveCrossFileReferences({
298
+ entryUri: "/workspace/main.conf",
299
+ documents: [
300
+ {
301
+ uri: "/workspace/main.conf",
302
+ text: `
303
+ include "templates/a.conf";
304
+ include "templates/b.conf";
305
+ include "templates/c.conf";
306
+ `,
307
+ },
308
+ {
309
+ uri: "/workspace/templates/a.conf",
310
+ text: `
311
+ template bgp a from b {
312
+ }
313
+ `,
314
+ },
315
+ {
316
+ uri: "/workspace/templates/b.conf",
317
+ text: `
318
+ template bgp b from c {
319
+ }
320
+ `,
321
+ },
322
+ {
323
+ uri: "/workspace/templates/c.conf",
324
+ text: `
325
+ template bgp c from a {
326
+ }
327
+ `,
328
+ },
329
+ ],
330
+ });
331
+
332
+ expect(
333
+ result.diagnostics.some(
334
+ (item) => item.code === "semantic/circular-template",
335
+ ),
336
+ ).toBe(true);
337
+ });
338
+
339
+ it("loads include files through readFileText fallback", async () => {
340
+ const readFileText = async (uri: string): Promise<string> => {
341
+ if (uri === "file:///workspace/templates/common.conf") {
342
+ return `
343
+ template bgp edge_tpl {
344
+ }
345
+ `;
346
+ }
347
+
348
+ throw new Error(`missing ${uri}`);
349
+ };
350
+
351
+ const result = await resolveCrossFileReferences({
352
+ entryUri: "file:///workspace/main.conf",
353
+ documents: [
354
+ {
355
+ uri: "file:///workspace/main.conf",
356
+ text: `
357
+ include "./templates/common.conf";
358
+ protocol bgp edge from edge_tpl {
359
+ }
360
+ `,
361
+ },
362
+ ],
363
+ readFileText,
364
+ });
365
+
366
+ expect(result.visitedUris).toContain(
367
+ "file:///workspace/templates/common.conf",
368
+ );
369
+ expect(result.stats.loadedFromFileSystem).toBe(1);
370
+ expect(
371
+ result.diagnostics.some(
372
+ (item) => item.code === "semantic/undefined-reference",
373
+ ),
374
+ ).toBe(false);
375
+ });
376
+
377
+ it("emits include warning when max depth is reached", async () => {
378
+ const result = await resolveCrossFileReferences({
379
+ entryUri: "/workspace/main.conf",
380
+ documents: [
381
+ {
382
+ uri: "/workspace/main.conf",
383
+ text: `include "a.conf";`,
384
+ },
385
+ {
386
+ uri: "/workspace/a.conf",
387
+ text: `include "b.conf";`,
388
+ },
389
+ {
390
+ uri: "/workspace/b.conf",
391
+ text: `template bgp edge_tpl { }`,
392
+ },
393
+ ],
394
+ maxDepth: 0,
395
+ });
396
+
397
+ expect(result.stats.skippedByDepth).toBeGreaterThan(0);
398
+ expect(
399
+ result.diagnostics.some((item) => item.message.includes("max depth")),
400
+ ).toBe(true);
401
+ });
402
+
403
+ it("emits include warning when max files limit is reached", async () => {
404
+ const result = await resolveCrossFileReferences({
405
+ entryUri: "/workspace/main.conf",
406
+ documents: [
407
+ {
408
+ uri: "/workspace/main.conf",
409
+ text: `
410
+ include "a.conf";
411
+ include "b.conf";
412
+ `,
413
+ },
414
+ {
415
+ uri: "/workspace/a.conf",
416
+ text: `template bgp a_tpl { }`,
417
+ },
418
+ {
419
+ uri: "/workspace/b.conf",
420
+ text: `template bgp b_tpl { }`,
421
+ },
422
+ ],
423
+ maxFiles: 1,
424
+ });
425
+
426
+ expect(result.stats.skippedByFileLimit).toBeGreaterThan(0);
427
+ expect(
428
+ result.diagnostics.some((item) => item.message.includes("max files")),
429
+ ).toBe(true);
430
+ });
431
+
432
+ it("skips include paths outside workspace root by default", async () => {
433
+ const result = await resolveCrossFileReferences({
434
+ entryUri: "/workspace/main.conf",
435
+ documents: [
436
+ {
437
+ uri: "/workspace/main.conf",
438
+ text: `
439
+ include "../outside.conf";
440
+ include "inside.conf";
441
+ `,
442
+ },
443
+ {
444
+ uri: "/workspace/inside.conf",
445
+ text: `template bgp inside_tpl { }`,
446
+ },
447
+ {
448
+ uri: "/outside.conf",
449
+ text: `template bgp outside_tpl { }`,
450
+ },
451
+ ],
452
+ });
453
+
454
+ expect(result.visitedUris).toContain("/workspace/inside.conf");
455
+ expect(result.visitedUris).not.toContain("/outside.conf");
456
+ expect(
457
+ result.diagnostics.some((item) =>
458
+ item.message.includes("outside workspace root"),
459
+ ),
460
+ ).toBe(true);
461
+ });
462
+
463
+ it("allows include paths outside workspace root when explicitly enabled", async () => {
464
+ const result = await resolveCrossFileReferences({
465
+ entryUri: "/workspace/main.conf",
466
+ allowIncludeOutsideWorkspace: true,
467
+ documents: [
468
+ {
469
+ uri: "/workspace/main.conf",
470
+ text: `include "../outside.conf";`,
471
+ },
472
+ {
473
+ uri: "/outside.conf",
474
+ text: `template bgp outside_tpl { }`,
475
+ },
476
+ ],
477
+ });
478
+
479
+ expect(result.visitedUris).toContain("/outside.conf");
480
+ });
481
+
482
+ it("reuses parsed document cache across repeated cross-file resolution calls", async () => {
483
+ const options = {
484
+ entryUri: "/workspace/main.conf",
485
+ documents: [
486
+ {
487
+ uri: "/workspace/main.conf",
488
+ text: `include "inside.conf";`,
489
+ },
490
+ {
491
+ uri: "/workspace/inside.conf",
492
+ text: `template bgp cached_tpl { }`,
493
+ },
494
+ ],
495
+ };
496
+
497
+ const first = await resolveCrossFileReferences(options);
498
+ const second = await resolveCrossFileReferences(options);
499
+
500
+ expect(first.stats.parsedCacheMisses).toBeGreaterThan(0);
501
+ expect(second.stats.parsedCacheHits).toBeGreaterThan(0);
502
+ });
503
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isValidPrefixLiteral } from "../src/prefix.js";
3
+
4
+ describe("isValidPrefixLiteral", () => {
5
+ it("accepts valid IPv4 and IPv6 prefix literals", () => {
6
+ expect(isValidPrefixLiteral("10.0.0.0/8")).toBe(true);
7
+ expect(isValidPrefixLiteral("10.0.0.0/8+")).toBe(true);
8
+ expect(isValidPrefixLiteral("10.0.0.0/8-")).toBe(true);
9
+ expect(isValidPrefixLiteral("10.0.0.0/8{16,24}")).toBe(true);
10
+ expect(isValidPrefixLiteral("2001:db8::/32")).toBe(true);
11
+ expect(isValidPrefixLiteral("2001:db8::/32{48,64}")).toBe(true);
12
+ });
13
+
14
+ it("rejects malformed literals", () => {
15
+ expect(isValidPrefixLiteral("")).toBe(false);
16
+ expect(isValidPrefixLiteral("10.0.0.0")).toBe(false);
17
+ expect(isValidPrefixLiteral("10.0.0.0/")).toBe(false);
18
+ expect(isValidPrefixLiteral("/24")).toBe(false);
19
+ expect(isValidPrefixLiteral("invalid/24")).toBe(false);
20
+ expect(isValidPrefixLiteral("10.0.0.0/8{16}")).toBe(false);
21
+ expect(isValidPrefixLiteral("10.0.0.0/8{16,}")).toBe(false);
22
+ expect(isValidPrefixLiteral("10.0.0.0/8{,24}")).toBe(false);
23
+ expect(isValidPrefixLiteral("10.0.0.0/8{a,24}")).toBe(false);
24
+ expect(isValidPrefixLiteral("10.0.0.0/8{16,a}")).toBe(false);
25
+ });
26
+
27
+ it("rejects out-of-range prefix lengths", () => {
28
+ expect(isValidPrefixLiteral("10.0.0.0/33")).toBe(false);
29
+ expect(isValidPrefixLiteral("2001:db8::/129")).toBe(false);
30
+ expect(isValidPrefixLiteral("10.0.0.0/-1")).toBe(false);
31
+ });
32
+
33
+ it("rejects invalid prefix range constraints", () => {
34
+ expect(isValidPrefixLiteral("10.0.0.0/8{7,24}")).toBe(false);
35
+ expect(isValidPrefixLiteral("10.0.0.0/8{16,40}")).toBe(false);
36
+ expect(isValidPrefixLiteral("10.0.0.0/8{24,16}")).toBe(false);
37
+ expect(isValidPrefixLiteral("2001:db8::/32{16,64}")).toBe(false);
38
+ expect(isValidPrefixLiteral("2001:db8::/32{48,140}")).toBe(false);
39
+ });
40
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }