@intentius/chant-lexicon-prometheus 0.96.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 (237) hide show
  1. package/README.md +58 -0
  2. package/dist/alertmanager.d.ts +81 -0
  3. package/dist/alertmanager.d.ts.map +1 -0
  4. package/dist/build.d.ts +33 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/catalog.d.ts +17 -0
  7. package/dist/catalog.d.ts.map +1 -0
  8. package/dist/codegen/docs-cli.d.ts +3 -0
  9. package/dist/codegen/docs-cli.d.ts.map +1 -0
  10. package/dist/codegen/docs.d.ts +9 -0
  11. package/dist/codegen/docs.d.ts.map +1 -0
  12. package/dist/codegen/generate-cli.d.ts +3 -0
  13. package/dist/codegen/generate-cli.d.ts.map +1 -0
  14. package/dist/codegen/generate.d.ts +19 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +7 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/catalog.d.ts +3 -0
  19. package/dist/composites/catalog.d.ts.map +1 -0
  20. package/dist/composites/index.d.ts +6 -0
  21. package/dist/composites/index.d.ts.map +1 -0
  22. package/dist/composites/slo.d.ts +205 -0
  23. package/dist/composites/slo.d.ts.map +1 -0
  24. package/dist/detect.d.ts +2 -0
  25. package/dist/detect.d.ts.map +1 -0
  26. package/dist/duration.d.ts +16 -0
  27. package/dist/duration.d.ts.map +1 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/init-templates.d.ts +13 -0
  31. package/dist/init-templates.d.ts.map +1 -0
  32. package/dist/integrity.json +33 -0
  33. package/dist/lint/audit-catalog.d.ts +13 -0
  34. package/dist/lint/audit-catalog.d.ts.map +1 -0
  35. package/dist/lint/post-synth/index.d.ts +3 -0
  36. package/dist/lint/post-synth/index.d.ts.map +1 -0
  37. package/dist/lint/post-synth/prom-helpers.d.ts +33 -0
  38. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -0
  39. package/dist/lint/post-synth/prom101.d.ts +8 -0
  40. package/dist/lint/post-synth/prom101.d.ts.map +1 -0
  41. package/dist/lint/post-synth/prom102.d.ts +8 -0
  42. package/dist/lint/post-synth/prom102.d.ts.map +1 -0
  43. package/dist/lint/post-synth/prom103.d.ts +8 -0
  44. package/dist/lint/post-synth/prom103.d.ts.map +1 -0
  45. package/dist/lint/post-synth/prom104.d.ts +8 -0
  46. package/dist/lint/post-synth/prom104.d.ts.map +1 -0
  47. package/dist/lint/post-synth/prom105.d.ts +8 -0
  48. package/dist/lint/post-synth/prom105.d.ts.map +1 -0
  49. package/dist/lint/post-synth/prom106.d.ts +8 -0
  50. package/dist/lint/post-synth/prom106.d.ts.map +1 -0
  51. package/dist/lint/post-synth/prom107.d.ts +8 -0
  52. package/dist/lint/post-synth/prom107.d.ts.map +1 -0
  53. package/dist/lint/post-synth/prom201.d.ts +8 -0
  54. package/dist/lint/post-synth/prom201.d.ts.map +1 -0
  55. package/dist/lint/post-synth/prom202.d.ts +8 -0
  56. package/dist/lint/post-synth/prom202.d.ts.map +1 -0
  57. package/dist/lint/post-synth/prom203.d.ts +8 -0
  58. package/dist/lint/post-synth/prom203.d.ts.map +1 -0
  59. package/dist/lint/post-synth/prom204.d.ts +8 -0
  60. package/dist/lint/post-synth/prom204.d.ts.map +1 -0
  61. package/dist/lint/post-synth/prom205.d.ts +8 -0
  62. package/dist/lint/post-synth/prom205.d.ts.map +1 -0
  63. package/dist/lint/post-synth/prom206.d.ts +8 -0
  64. package/dist/lint/post-synth/prom206.d.ts.map +1 -0
  65. package/dist/lint/post-synth/prom207.d.ts +8 -0
  66. package/dist/lint/post-synth/prom207.d.ts.map +1 -0
  67. package/dist/lint/post-synth/prom208.d.ts +8 -0
  68. package/dist/lint/post-synth/prom208.d.ts.map +1 -0
  69. package/dist/lint/post-synth/prom209.d.ts +8 -0
  70. package/dist/lint/post-synth/prom209.d.ts.map +1 -0
  71. package/dist/lint/rules/index.d.ts +7 -0
  72. package/dist/lint/rules/index.d.ts.map +1 -0
  73. package/dist/lint/rules/literal-credential.d.ts +18 -0
  74. package/dist/lint/rules/literal-credential.d.ts.map +1 -0
  75. package/dist/lint/rules/prom-ast.d.ts +14 -0
  76. package/dist/lint/rules/prom-ast.d.ts.map +1 -0
  77. package/dist/lint/rules/promql-literal.d.ts +11 -0
  78. package/dist/lint/rules/promql-literal.d.ts.map +1 -0
  79. package/dist/lint/rules/slo-literal.d.ts +13 -0
  80. package/dist/lint/rules/slo-literal.d.ts.map +1 -0
  81. package/dist/lsp/completions.d.ts +4 -0
  82. package/dist/lsp/completions.d.ts.map +1 -0
  83. package/dist/lsp/hover.d.ts +4 -0
  84. package/dist/lsp/hover.d.ts.map +1 -0
  85. package/dist/manifest.json +8 -0
  86. package/dist/matchers.d.ts +29 -0
  87. package/dist/matchers.d.ts.map +1 -0
  88. package/dist/meta.json +32 -0
  89. package/dist/model.d.ts +262 -0
  90. package/dist/model.d.ts.map +1 -0
  91. package/dist/okf/index.md +34 -0
  92. package/dist/okf/rules/PROM001.md +16 -0
  93. package/dist/okf/rules/PROM002.md +15 -0
  94. package/dist/okf/rules/PROM003.md +11 -0
  95. package/dist/okf/rules/PROM101.md +11 -0
  96. package/dist/okf/rules/PROM102.md +11 -0
  97. package/dist/okf/rules/PROM103.md +11 -0
  98. package/dist/okf/rules/PROM104.md +11 -0
  99. package/dist/okf/rules/PROM105.md +11 -0
  100. package/dist/okf/rules/PROM106.md +11 -0
  101. package/dist/okf/rules/PROM107.md +11 -0
  102. package/dist/okf/rules/PROM201.md +11 -0
  103. package/dist/okf/rules/PROM202.md +11 -0
  104. package/dist/okf/rules/PROM203.md +11 -0
  105. package/dist/okf/rules/PROM204.md +11 -0
  106. package/dist/okf/rules/PROM205.md +11 -0
  107. package/dist/okf/rules/PROM206.md +11 -0
  108. package/dist/okf/rules/PROM207.md +11 -0
  109. package/dist/okf/rules/PROM208.md +11 -0
  110. package/dist/okf/rules/PROM209.md +11 -0
  111. package/dist/okf/types/AlertmanagerSettings.md +13 -0
  112. package/dist/okf/types/InhibitRule.md +9 -0
  113. package/dist/okf/types/Receiver.md +13 -0
  114. package/dist/okf/types/Route.md +9 -0
  115. package/dist/okf/types/RuleGroup.md +13 -0
  116. package/dist/okf/types/TimeInterval.md +9 -0
  117. package/dist/package-cli.d.ts +3 -0
  118. package/dist/package-cli.d.ts.map +1 -0
  119. package/dist/pin.d.ts +20 -0
  120. package/dist/pin.d.ts.map +1 -0
  121. package/dist/plugin.d.ts +10 -0
  122. package/dist/plugin.d.ts.map +1 -0
  123. package/dist/promql.d.ts +25 -0
  124. package/dist/promql.d.ts.map +1 -0
  125. package/dist/rule-eval.d.ts +67 -0
  126. package/dist/rule-eval.d.ts.map +1 -0
  127. package/dist/rules/literal-credential.ts +84 -0
  128. package/dist/rules/prom-ast.ts +30 -0
  129. package/dist/rules/prom-helpers.ts +89 -0
  130. package/dist/rules/prom101.ts +17 -0
  131. package/dist/rules/prom102.ts +17 -0
  132. package/dist/rules/prom103.ts +17 -0
  133. package/dist/rules/prom104.ts +17 -0
  134. package/dist/rules/prom105.ts +17 -0
  135. package/dist/rules/prom106.ts +17 -0
  136. package/dist/rules/prom107.ts +17 -0
  137. package/dist/rules/prom201.ts +17 -0
  138. package/dist/rules/prom202.ts +17 -0
  139. package/dist/rules/prom203.ts +17 -0
  140. package/dist/rules/prom204.ts +17 -0
  141. package/dist/rules/prom205.ts +17 -0
  142. package/dist/rules/prom206.ts +17 -0
  143. package/dist/rules/prom207.ts +17 -0
  144. package/dist/rules/prom208.ts +17 -0
  145. package/dist/rules/prom209.ts +17 -0
  146. package/dist/rules/promql-literal.ts +55 -0
  147. package/dist/rules/slo-literal.ts +81 -0
  148. package/dist/rules.d.ts +59 -0
  149. package/dist/rules.d.ts.map +1 -0
  150. package/dist/serializer.d.ts +27 -0
  151. package/dist/serializer.d.ts.map +1 -0
  152. package/dist/skill-defs.d.ts +3 -0
  153. package/dist/skill-defs.d.ts.map +1 -0
  154. package/dist/skills/chant-prometheus-alertmanager.md +58 -0
  155. package/dist/skills/chant-prometheus-kubernetes.md +48 -0
  156. package/dist/skills/chant-prometheus.md +91 -0
  157. package/dist/tools.d.ts +30 -0
  158. package/dist/tools.d.ts.map +1 -0
  159. package/dist/types/index.d.ts +3 -0
  160. package/dist/validate-cli.d.ts +3 -0
  161. package/dist/validate-cli.d.ts.map +1 -0
  162. package/dist/validate-config.d.ts +35 -0
  163. package/dist/validate-config.d.ts.map +1 -0
  164. package/dist/validate.d.ts +11 -0
  165. package/dist/validate.d.ts.map +1 -0
  166. package/package.json +77 -0
  167. package/src/alertmanager.ts +143 -0
  168. package/src/build.ts +199 -0
  169. package/src/catalog.ts +65 -0
  170. package/src/codegen/docs-cli.ts +4 -0
  171. package/src/codegen/docs.ts +104 -0
  172. package/src/codegen/generate-cli.ts +8 -0
  173. package/src/codegen/generate.ts +49 -0
  174. package/src/codegen/package.ts +41 -0
  175. package/src/composites/catalog.test.ts +37 -0
  176. package/src/composites/catalog.ts +65 -0
  177. package/src/composites/composites.test.ts +256 -0
  178. package/src/composites/index.ts +23 -0
  179. package/src/composites/slo-burn.test.ts +289 -0
  180. package/src/composites/slo.ts +471 -0
  181. package/src/detect.ts +12 -0
  182. package/src/duration.ts +51 -0
  183. package/src/generated/lexicon-prometheus.json +32 -0
  184. package/src/index.ts +97 -0
  185. package/src/init-templates.test.ts +29 -0
  186. package/src/init-templates.ts +76 -0
  187. package/src/lint/audit-catalog.ts +123 -0
  188. package/src/lint/post-synth/index.ts +37 -0
  189. package/src/lint/post-synth/post-synth.test.ts +256 -0
  190. package/src/lint/post-synth/prom-helpers.ts +89 -0
  191. package/src/lint/post-synth/prom101.ts +17 -0
  192. package/src/lint/post-synth/prom102.ts +17 -0
  193. package/src/lint/post-synth/prom103.ts +17 -0
  194. package/src/lint/post-synth/prom104.ts +17 -0
  195. package/src/lint/post-synth/prom105.ts +17 -0
  196. package/src/lint/post-synth/prom106.ts +17 -0
  197. package/src/lint/post-synth/prom107.ts +17 -0
  198. package/src/lint/post-synth/prom201.ts +17 -0
  199. package/src/lint/post-synth/prom202.ts +17 -0
  200. package/src/lint/post-synth/prom203.ts +17 -0
  201. package/src/lint/post-synth/prom204.ts +17 -0
  202. package/src/lint/post-synth/prom205.ts +17 -0
  203. package/src/lint/post-synth/prom206.ts +17 -0
  204. package/src/lint/post-synth/prom207.ts +17 -0
  205. package/src/lint/post-synth/prom208.ts +17 -0
  206. package/src/lint/post-synth/prom209.ts +17 -0
  207. package/src/lint/rules/index.ts +11 -0
  208. package/src/lint/rules/literal-credential.ts +84 -0
  209. package/src/lint/rules/prom-ast.ts +30 -0
  210. package/src/lint/rules/promql-literal.ts +55 -0
  211. package/src/lint/rules/rules.test.ts +114 -0
  212. package/src/lint/rules/slo-literal.ts +81 -0
  213. package/src/lsp/completions.test.ts +23 -0
  214. package/src/lsp/completions.ts +15 -0
  215. package/src/lsp/hover.test.ts +27 -0
  216. package/src/lsp/hover.ts +26 -0
  217. package/src/matchers.ts +132 -0
  218. package/src/model.test.ts +104 -0
  219. package/src/model.ts +290 -0
  220. package/src/package-cli.ts +17 -0
  221. package/src/pin.ts +13 -0
  222. package/src/plugin.test.ts +73 -0
  223. package/src/plugin.ts +129 -0
  224. package/src/promql.ts +46 -0
  225. package/src/rule-eval.ts +397 -0
  226. package/src/rules.ts +127 -0
  227. package/src/serializer.test.ts +179 -0
  228. package/src/serializer.ts +55 -0
  229. package/src/skill-defs.ts +43 -0
  230. package/src/skills/chant-prometheus-alertmanager.md +58 -0
  231. package/src/skills/chant-prometheus-kubernetes.md +48 -0
  232. package/src/skills/chant-prometheus.md +91 -0
  233. package/src/tools.test.ts +119 -0
  234. package/src/tools.ts +70 -0
  235. package/src/validate-cli.ts +7 -0
  236. package/src/validate-config.ts +400 -0
  237. package/src/validate.ts +66 -0
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hover } from "./hover";
3
+
4
+ describe("prometheus LSP hover", () => {
5
+ it("describes RuleGroup and the file it lands in", () => {
6
+ const info = hover({
7
+ uri: "file:///rules.ts",
8
+ content: "new RuleGroup({})",
9
+ position: { line: 0, character: 6 },
10
+ word: "RuleGroup",
11
+ lineText: "new RuleGroup({})",
12
+ });
13
+ expect(info?.contents).toContain("Prometheus::Rules::RuleGroup");
14
+ expect(info?.contents).toContain("rule file");
15
+ expect(info?.contents).toContain("v3.15.0");
16
+ });
17
+
18
+ it("describes an Alertmanager entity against the Alertmanager pin", () => {
19
+ const info = hover({ uri: "file:///am.ts", content: "new Receiver({})", position: { line: 0, character: 6 }, word: "Receiver", lineText: "new Receiver({})" });
20
+ expect(info?.contents).toContain("alertmanager.yml");
21
+ expect(info?.contents).toContain("v0.34.1");
22
+ });
23
+
24
+ it("returns undefined for an unknown word", () => {
25
+ expect(hover({ uri: "file:///x.ts", content: "", position: { line: 0, character: 0 }, word: "Nope", lineText: "" })).toBeUndefined();
26
+ });
27
+ });
@@ -0,0 +1,26 @@
1
+ import type { HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
2
+ import { LexiconIndex, lexiconHover, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
3
+ import { CATALOG, lexiconRegistry } from "../catalog";
4
+ import { PROMETHEUS_PIN } from "../pin";
5
+
6
+ let cachedIndex: LexiconIndex | null = null;
7
+
8
+ function getIndex(): LexiconIndex {
9
+ cachedIndex ??= new LexiconIndex(lexiconRegistry());
10
+ return cachedIndex;
11
+ }
12
+
13
+ /** Hover for prometheus entity classes: what the entity is and which file it lands in. */
14
+ export function hover(ctx: HoverContext): HoverInfo | undefined {
15
+ return lexiconHover(ctx, getIndex(), resourceHover);
16
+ }
17
+
18
+ function resourceHover(className: string, entry: LexiconEntry): HoverInfo | undefined {
19
+ const cat = CATALOG.find((c) => c.className === className);
20
+ const lines = [`**${className}**`, "", `prometheus type: \`${entry.resourceType}\``];
21
+ if (cat) {
22
+ const pin = cat.file === "rule file" ? PROMETHEUS_PIN.prometheus : PROMETHEUS_PIN.alertmanager;
23
+ lines.push("", cat.description, "", `Serializes into the ${cat.file}. Typed against ${pin.source} ${pin.version}.`);
24
+ }
25
+ return { contents: lines.join("\n") };
26
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Alertmanager label matchers: `severity="critical"`, `team=~"db|infra"`,
3
+ * `env!="dev"`.
4
+ *
5
+ * A matcher is a label name, an operator (`=`, `!=`, `=~`, `!~`) and a value,
6
+ * quoted or not. One list entry may also hold several comma-separated
7
+ * matchers in braces (`{a="1", b="2"}`), which Alertmanager accepts too.
8
+ * Regex values are anchored at both ends, as Alertmanager anchors them.
9
+ */
10
+
11
+ export type MatchOp = "=" | "!=" | "=~" | "!~";
12
+
13
+ export interface Matcher {
14
+ name: string;
15
+ op: MatchOp;
16
+ value: string;
17
+ }
18
+
19
+ export type ParsedMatchers = { ok: true; matchers: Matcher[] } | { ok: false; error: string };
20
+
21
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
22
+
23
+ function readQuoted(s: string, start: number): { value: string; end: number } | undefined {
24
+ let i = start + 1;
25
+ let out = "";
26
+ while (i < s.length) {
27
+ const c = s[i];
28
+ if (c === "\\") {
29
+ const next = s[i + 1];
30
+ if (next === undefined) return undefined;
31
+ out += next === "n" ? "\n" : next === "t" ? "\t" : next;
32
+ i += 2;
33
+ continue;
34
+ }
35
+ if (c === '"') return { value: out, end: i + 1 };
36
+ out += c;
37
+ i++;
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function parseOne(text: string): Matcher | string {
43
+ const s = text.trim();
44
+ const opMatch = /(=~|!~|!=|=)/.exec(s);
45
+ if (!opMatch) return `"${s}" has no operator; use =, !=, =~ or !~`;
46
+ let name = s.slice(0, opMatch.index).trim();
47
+ if (name.startsWith('"') && name.endsWith('"') && name.length >= 2) name = name.slice(1, -1);
48
+ else if (!NAME.test(name)) return `"${name}" is not a label name`;
49
+ if (name === "") return `"${s}" has an empty label name`;
50
+ const op = opMatch[1] as MatchOp;
51
+ const rest = s.slice(opMatch.index + op.length).trim();
52
+ let value: string;
53
+ if (rest.startsWith('"')) {
54
+ const q = readQuoted(rest, 0);
55
+ if (!q) return `"${s}" has an unterminated quoted value`;
56
+ if (rest.slice(q.end).trim() !== "") return `"${s}" has text after its quoted value`;
57
+ value = q.value;
58
+ } else {
59
+ if (/["{}=!~,]/.test(rest)) return `"${s}" has an unquoted value with reserved characters; quote it`;
60
+ value = rest;
61
+ }
62
+ if (op === "=~" || op === "!~") {
63
+ try {
64
+ new RegExp(`^(?:${value})$`);
65
+ } catch (err) {
66
+ return `"${s}" has an invalid regular expression: ${err instanceof Error ? err.message : String(err)}`;
67
+ }
68
+ }
69
+ return { name, op, value };
70
+ }
71
+
72
+ /** Split `a="x,y", b=z` on the commas that are outside quotes. */
73
+ function splitTopLevel(s: string): string[] {
74
+ const parts: string[] = [];
75
+ let depthQuote = false;
76
+ let cur = "";
77
+ for (let i = 0; i < s.length; i++) {
78
+ const c = s[i];
79
+ if (c === "\\" && depthQuote) {
80
+ cur += c + (s[i + 1] ?? "");
81
+ i++;
82
+ continue;
83
+ }
84
+ if (c === '"') depthQuote = !depthQuote;
85
+ if (c === "," && !depthQuote) {
86
+ parts.push(cur);
87
+ cur = "";
88
+ continue;
89
+ }
90
+ cur += c;
91
+ }
92
+ parts.push(cur);
93
+ return parts.map((p) => p.trim()).filter((p) => p !== "");
94
+ }
95
+
96
+ /** Parse one entry of a `matchers:` list. */
97
+ export function parseMatchers(entry: string): ParsedMatchers {
98
+ let s = entry.trim();
99
+ if (s === "") return { ok: false, error: "empty matcher" };
100
+ if (s.startsWith("{")) {
101
+ if (!s.endsWith("}")) return { ok: false, error: `"${entry}" opens a brace it never closes` };
102
+ s = s.slice(1, -1);
103
+ }
104
+ const matchers: Matcher[] = [];
105
+ for (const part of splitTopLevel(s)) {
106
+ const m = parseOne(part);
107
+ if (typeof m === "string") return { ok: false, error: m };
108
+ matchers.push(m);
109
+ }
110
+ if (matchers.length === 0) return { ok: false, error: `"${entry}" holds no matcher` };
111
+ return { ok: true, matchers };
112
+ }
113
+
114
+ /** Whether one matcher matches a label set (a missing label reads as the empty string, as in Alertmanager). */
115
+ export function matcherMatches(m: Matcher, labels: Record<string, string>): boolean {
116
+ const v = labels[m.name] ?? "";
117
+ switch (m.op) {
118
+ case "=":
119
+ return v === m.value;
120
+ case "!=":
121
+ return v !== m.value;
122
+ case "=~":
123
+ return new RegExp(`^(?:${m.value})$`).test(v);
124
+ case "!~":
125
+ return !new RegExp(`^(?:${m.value})$`).test(v);
126
+ }
127
+ }
128
+
129
+ /** Build the matcher string for `name op value`, quoting the value. */
130
+ export function matcher(name: string, op: MatchOp, value: string): string {
131
+ return `${name}${op}${JSON.stringify(value)}`;
132
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { durationMs, formatDuration, isValidDuration } from "./duration";
3
+ import { matcher, matcherMatches, parseMatchers, type Matcher } from "./matchers";
4
+ import { checkPromql } from "./promql";
5
+ import { looksLikeAlertmanagerConfig, looksLikeRuleFile } from "./model";
6
+
7
+ describe("durations", () => {
8
+ test.each(["0", "30s", "5m", "1h30m", "1d", "2w", "1y", "500ms", "1h0m0s", "1y2w3d4h5m6s7ms"])("%s is valid", (d) => {
9
+ expect(isValidDuration(d)).toBe(true);
10
+ });
11
+
12
+ test.each(["", "5", "1.5h", "30m1h", "5 m", "5min", "-1m", "1h1h", "m"])("%j is not", (d) => {
13
+ expect(isValidDuration(d)).toBe(false);
14
+ });
15
+
16
+ test("durationMs and formatDuration agree", () => {
17
+ expect(durationMs("1h30m")).toBe(5_400_000);
18
+ expect(durationMs("2d")).toBe(172_800_000);
19
+ expect(durationMs("nope")).toBeUndefined();
20
+ expect(formatDuration(5_400_000)).toBe("1h30m");
21
+ expect(formatDuration(0)).toBe("0s");
22
+ for (const d of ["28d", "6h", "30m", "1h5m", "4w"]) expect(durationMs(formatDuration(durationMs(d)!))).toBe(durationMs(d));
23
+ expect(() => formatDuration(1.5)).toThrow();
24
+ });
25
+ });
26
+
27
+ describe("matchers", () => {
28
+ test("parses the four operators, quoted and unquoted", () => {
29
+ expect(parseMatchers('severity="page"')).toEqual({ ok: true, matchers: [{ name: "severity", op: "=", value: "page" }] });
30
+ expect(parseMatchers("severity!=info")).toEqual({ ok: true, matchers: [{ name: "severity", op: "!=", value: "info" }] });
31
+ expect(parseMatchers('team=~"db|infra"')).toMatchObject({ ok: true, matchers: [{ op: "=~" }] });
32
+ expect(parseMatchers('env!~"dev.*"')).toMatchObject({ ok: true, matchers: [{ op: "!~" }] });
33
+ });
34
+
35
+ test("parses a braced list and quoted commas", () => {
36
+ const r = parseMatchers('{severity="page", msg="a, b"}');
37
+ expect(r).toEqual({ ok: true, matchers: [{ name: "severity", op: "=", value: "page" }, { name: "msg", op: "=", value: "a, b" }] });
38
+ });
39
+
40
+ test.each(['severity', '="x"', 'sev-erity="x"', 'a="unterminated', 'a=~"("', "{a=b", "a=b c=d"])("rejects %j", (m) => {
41
+ expect(parseMatchers(m).ok).toBe(false);
42
+ });
43
+
44
+ test("matches like Alertmanager, regexes anchored, missing labels empty", () => {
45
+ const one = (s: string): Matcher => {
46
+ const r = parseMatchers(s);
47
+ if (!r.ok) throw new Error(r.error);
48
+ return r.matchers[0];
49
+ };
50
+ const [eq, re, ne] = [one('severity="page"'), one('severity=~"pa"'), one('team!=""')];
51
+ expect(matcherMatches(eq, { severity: "page" })).toBe(true);
52
+ expect(matcherMatches(re, { severity: "page" })).toBe(false);
53
+ expect(matcherMatches(ne, {})).toBe(false);
54
+ });
55
+
56
+ test("matcher() quotes the value", () => {
57
+ expect(matcher("severity", "=", "page")).toBe('severity="page"');
58
+ expect(parseMatchers(matcher("msg", "=", 'say "hi"'))).toMatchObject({ ok: true, matchers: [{ value: 'say "hi"' }] });
59
+ });
60
+ });
61
+
62
+ describe("PromQL syntax", () => {
63
+ test.each([
64
+ 'sum by (job) (rate(http_requests_total{job="api"}[5m])) > 0.1',
65
+ "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))",
66
+ "1 - (sum(rate(good[1h])) / sum(rate(total[1h])))",
67
+ 'absent(up{job="api"} == 1)',
68
+ "rate(x[5m:1m]) offset 1h",
69
+ "a and on (job) b",
70
+ "up\n == 0",
71
+ ])("accepts %s", (q) => {
72
+ expect(checkPromql(q)).toEqual({ ok: true });
73
+ });
74
+
75
+ test.each([
76
+ ["rate(x[5m]", "ends early"],
77
+ ["x{a=\"b\"", "ends early"],
78
+ ["foo bar", "offset 4"],
79
+ ["rate(x[5q])", "syntax error"],
80
+ ["notafunction(x)", "syntax error"],
81
+ ['sum(rate(x[{{window}}]))', "syntax error"],
82
+ ["", "empty"],
83
+ ])("rejects %j", (q, msg) => {
84
+ const r = checkPromql(q);
85
+ expect(r.ok).toBe(false);
86
+ if (!r.ok) expect(r.message).toContain(msg);
87
+ });
88
+ });
89
+
90
+ describe("document shape", () => {
91
+ test("rule files", () => {
92
+ expect(looksLikeRuleFile({ groups: [] })).toBe(true);
93
+ expect(looksLikeRuleFile({ groups: [{ name: "a", rules: [] }] })).toBe(true);
94
+ expect(looksLikeRuleFile({ groups: [{ name: "a" }] })).toBe(false);
95
+ expect(looksLikeRuleFile({ spec: { groups: [] } })).toBe(false);
96
+ });
97
+
98
+ test("alertmanager configs", () => {
99
+ expect(looksLikeAlertmanagerConfig({ route: { receiver: "a" } })).toBe(true);
100
+ expect(looksLikeAlertmanagerConfig({ receivers: [] })).toBe(true);
101
+ expect(looksLikeAlertmanagerConfig({ apiVersion: "v1", kind: "Receiver", receivers: [] })).toBe(false);
102
+ expect(looksLikeAlertmanagerConfig({ receivers: { otlp: {} } })).toBe(false);
103
+ });
104
+ });
package/src/model.ts ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The plain-data model of a Prometheus rule file and an Alertmanager config.
3
+ *
4
+ * Everything else in this lexicon reads or writes these shapes: the serializer
5
+ * builds them from declared entities, the YAML emitter prints them, the checks
6
+ * validate them, and other lexicons (the k8s `PrometheusRule`, the SLO
7
+ * composite, Grafana panels) read them. They are exactly the files' own
8
+ * layouts, so a parsed rule file or `alertmanager.yml` already is one and
9
+ * needs no conversion.
10
+ *
11
+ * Field names keep the files' snake_case (`keep_firing_for`, `group_wait`),
12
+ * so a declaration reads the same as the file it produces and the upstream
13
+ * docs apply word for word.
14
+ */
15
+
16
+ /** Labels or annotations: string keys to string values. */
17
+ export type LabelSet = Record<string, string>;
18
+
19
+ /** A recording rule: evaluates `expr` and stores the result as the series `record`. */
20
+ export interface RecordingRuleConfig {
21
+ /** The metric name the result is stored under, e.g. `job:http_requests:rate5m`. */
22
+ record: string;
23
+ /** The PromQL expression. */
24
+ expr: string;
25
+ /** Labels added to the stored series. */
26
+ labels?: LabelSet;
27
+ }
28
+
29
+ /** An alerting rule: fires while `expr` returns anything, after `for`. */
30
+ export interface AlertingRuleConfig {
31
+ /** The alert name, the `alertname` label on every alert it fires. */
32
+ alert: string;
33
+ /** The PromQL expression. Every series it returns is one alert. */
34
+ expr: string;
35
+ /** How long the expression must hold before the alert fires, e.g. `5m`. */
36
+ for?: string;
37
+ /** How long the alert keeps firing after the expression stops returning it. */
38
+ keep_firing_for?: string;
39
+ /** Labels added to the alert. Alertmanager routes on these; set `severity`. */
40
+ labels?: LabelSet;
41
+ /** Annotations added to the alert, e.g. `summary`, `description`, `runbook_url`. */
42
+ annotations?: LabelSet;
43
+ }
44
+
45
+ export type RuleConfig = RecordingRuleConfig | AlertingRuleConfig;
46
+
47
+ /** One entry under `groups:` in a rule file, and one entry in a `PrometheusRule`'s `spec.groups`. */
48
+ export interface RuleGroupConfig {
49
+ name: string;
50
+ /** How often the group is evaluated. Defaults to the global `evaluation_interval`. */
51
+ interval?: string;
52
+ /** Evaluate the group this far in the past, for late-arriving data. */
53
+ query_offset?: string;
54
+ /** Cap on alerts (alerting rules) or series (recording rules) a rule may produce; 0 is no limit. */
55
+ limit?: number;
56
+ /** Labels added to every rule in the group. A rule's own labels win. */
57
+ labels?: LabelSet;
58
+ rules: RuleConfig[];
59
+ }
60
+
61
+ /** A whole rule file, the YAML `rule_files:` points at and `promtool check rules` reads. */
62
+ export interface RuleFileConfig {
63
+ groups: RuleGroupConfig[];
64
+ }
65
+
66
+ export function isRecordingRuleConfig(rule: unknown): rule is RecordingRuleConfig {
67
+ return typeof rule === "object" && rule !== null && typeof (rule as { record?: unknown }).record === "string";
68
+ }
69
+
70
+ export function isAlertingRuleConfig(rule: unknown): rule is AlertingRuleConfig {
71
+ return typeof rule === "object" && rule !== null && typeof (rule as { alert?: unknown }).alert === "string";
72
+ }
73
+
74
+ /** The rule's name: `record` for a recording rule, `alert` for an alerting rule. */
75
+ export function ruleName(rule: RuleConfig): string {
76
+ return isRecordingRuleConfig(rule) ? rule.record : (rule as AlertingRuleConfig).alert;
77
+ }
78
+
79
+ // ── Alertmanager ────────────────────────────────────────────────────
80
+
81
+ /** HTTP client settings shared by the receivers that make HTTP calls. */
82
+ export interface HttpClientConfig {
83
+ basic_auth?: { username?: string; username_file?: string; password?: string; password_file?: string };
84
+ authorization?: { type?: string; credentials?: string; credentials_file?: string };
85
+ bearer_token?: string;
86
+ bearer_token_file?: string;
87
+ oauth2?: Record<string, unknown>;
88
+ proxy_url?: string;
89
+ follow_redirects?: boolean;
90
+ enable_http2?: boolean;
91
+ tls_config?: {
92
+ ca_file?: string;
93
+ cert_file?: string;
94
+ key_file?: string;
95
+ server_name?: string;
96
+ insecure_skip_verify?: boolean;
97
+ min_version?: string;
98
+ };
99
+ }
100
+
101
+ interface NotifierBase {
102
+ /** Whether to notify about resolved alerts too. */
103
+ send_resolved?: boolean;
104
+ }
105
+
106
+ export interface WebhookConfig extends NotifierBase {
107
+ /** Where alerts are POSTed. Set this or `url_file`. */
108
+ url?: string;
109
+ url_file?: string;
110
+ http_config?: HttpClientConfig;
111
+ /** Most alerts per message; 0 sends them all. */
112
+ max_alerts?: number;
113
+ timeout?: string;
114
+ }
115
+
116
+ export interface EmailConfig extends NotifierBase {
117
+ to: string;
118
+ from?: string;
119
+ /** `host:port` of the SMTP server. Falls back to `global.smtp_smarthost`. */
120
+ smarthost?: string;
121
+ hello?: string;
122
+ auth_username?: string;
123
+ auth_password?: string;
124
+ auth_password_file?: string;
125
+ auth_secret?: string;
126
+ auth_secret_file?: string;
127
+ auth_identity?: string;
128
+ require_tls?: boolean;
129
+ tls_config?: HttpClientConfig["tls_config"];
130
+ html?: string;
131
+ text?: string;
132
+ headers?: Record<string, string>;
133
+ }
134
+
135
+ export interface SlackConfig extends NotifierBase {
136
+ /** The incoming-webhook URL. It is a credential: prefer `api_url_file`. */
137
+ api_url?: string;
138
+ api_url_file?: string;
139
+ channel?: string;
140
+ username?: string;
141
+ color?: string;
142
+ title?: string;
143
+ title_link?: string;
144
+ pretext?: string;
145
+ text?: string;
146
+ footer?: string;
147
+ fallback?: string;
148
+ icon_emoji?: string;
149
+ icon_url?: string;
150
+ link_names?: boolean;
151
+ short_fields?: boolean;
152
+ mrkdwn_in?: string[];
153
+ fields?: Array<{ title: string; value: string; short?: boolean }>;
154
+ actions?: Array<Record<string, unknown>>;
155
+ http_config?: HttpClientConfig;
156
+ }
157
+
158
+ export interface PagerDutyConfig extends NotifierBase {
159
+ /** Events API v2 integration key. A credential: prefer `routing_key_file`. */
160
+ routing_key?: string;
161
+ routing_key_file?: string;
162
+ /** Events API v1 integration key. A credential: prefer `service_key_file`. */
163
+ service_key?: string;
164
+ service_key_file?: string;
165
+ url?: string;
166
+ client?: string;
167
+ client_url?: string;
168
+ description?: string;
169
+ severity?: string;
170
+ class?: string;
171
+ component?: string;
172
+ group?: string;
173
+ source?: string;
174
+ details?: Record<string, string>;
175
+ links?: Array<{ href: string; text?: string }>;
176
+ images?: Array<{ src: string; alt?: string; href?: string }>;
177
+ http_config?: HttpClientConfig;
178
+ }
179
+
180
+ /** One entry under `receivers:`. */
181
+ export interface ReceiverConfig {
182
+ name: string;
183
+ webhook_configs?: WebhookConfig[];
184
+ email_configs?: EmailConfig[];
185
+ slack_configs?: SlackConfig[];
186
+ pagerduty_configs?: PagerDutyConfig[];
187
+ }
188
+
189
+ /** A route in the routing tree. The top-level `route:` is the root. */
190
+ export interface RouteConfig {
191
+ receiver?: string;
192
+ group_by?: string[];
193
+ continue?: boolean;
194
+ /** Matchers in Alertmanager's syntax, e.g. `severity="critical"` or `team=~"db|infra"`. */
195
+ matchers?: string[];
196
+ group_wait?: string;
197
+ group_interval?: string;
198
+ repeat_interval?: string;
199
+ mute_time_intervals?: string[];
200
+ active_time_intervals?: string[];
201
+ routes?: RouteConfig[];
202
+ }
203
+
204
+ /** One entry under `inhibit_rules:`. */
205
+ export interface InhibitRuleConfig {
206
+ source_matchers?: string[];
207
+ target_matchers?: string[];
208
+ /** Labels that must be equal on source and target for the inhibition to apply. */
209
+ equal?: string[];
210
+ }
211
+
212
+ export interface TimeRangeConfig {
213
+ start_time: string;
214
+ end_time: string;
215
+ }
216
+
217
+ /** One period inside a time interval. Every field left out matches everything. */
218
+ export interface TimePeriodConfig {
219
+ times?: TimeRangeConfig[];
220
+ /** e.g. `monday:friday`, `saturday`. */
221
+ weekdays?: string[];
222
+ /** e.g. `1:5`, `-1`. */
223
+ days_of_month?: string[];
224
+ /** e.g. `january:march`, `12`. */
225
+ months?: string[];
226
+ /** e.g. `2026:2027`. */
227
+ years?: string[];
228
+ /** An IANA time zone name, e.g. `Europe/Berlin`. Defaults to UTC. */
229
+ location?: string;
230
+ }
231
+
232
+ /** One entry under `time_intervals:`. */
233
+ export interface TimeIntervalConfig {
234
+ name: string;
235
+ time_intervals: TimePeriodConfig[];
236
+ }
237
+
238
+ /** `global:` settings. */
239
+ export interface AlertmanagerGlobalConfig {
240
+ resolve_timeout?: string;
241
+ smtp_from?: string;
242
+ smtp_smarthost?: string;
243
+ smtp_hello?: string;
244
+ smtp_auth_username?: string;
245
+ smtp_auth_password?: string;
246
+ smtp_auth_password_file?: string;
247
+ smtp_auth_secret?: string;
248
+ smtp_auth_secret_file?: string;
249
+ smtp_auth_identity?: string;
250
+ smtp_require_tls?: boolean;
251
+ slack_api_url?: string;
252
+ slack_api_url_file?: string;
253
+ pagerduty_url?: string;
254
+ http_config?: HttpClientConfig;
255
+ }
256
+
257
+ /** A whole `alertmanager.yml`, the file `amtool check-config` reads. */
258
+ export interface AlertmanagerConfig {
259
+ global?: AlertmanagerGlobalConfig;
260
+ templates?: string[];
261
+ route?: RouteConfig;
262
+ inhibit_rules?: InhibitRuleConfig[];
263
+ receivers?: ReceiverConfig[];
264
+ time_intervals?: TimeIntervalConfig[];
265
+ }
266
+
267
+ /** The four receiver integrations this lexicon types. */
268
+ export const RECEIVER_INTEGRATIONS = ["webhook_configs", "email_configs", "slack_configs", "pagerduty_configs"] as const;
269
+
270
+ /** True when a parsed document has the shape of a Prometheus rule file. */
271
+ export function looksLikeRuleFile(value: unknown): value is RuleFileConfig {
272
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
273
+ const groups = (value as Record<string, unknown>).groups;
274
+ if (!Array.isArray(groups)) return false;
275
+ return groups.every(
276
+ (g) => typeof g === "object" && g !== null && typeof (g as Record<string, unknown>).name === "string" && Array.isArray((g as Record<string, unknown>).rules),
277
+ );
278
+ }
279
+
280
+ /** True when a parsed document has the shape of an `alertmanager.yml`. */
281
+ export function looksLikeAlertmanagerConfig(value: unknown): value is AlertmanagerConfig {
282
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
283
+ const v = value as Record<string, unknown>;
284
+ const route = v.route;
285
+ const receivers = v.receivers;
286
+ const hasRoute = typeof route === "object" && route !== null && !Array.isArray(route);
287
+ const hasReceivers = Array.isArray(receivers);
288
+ // A k8s manifest never has both at the top level; a collector config has neither.
289
+ return (hasRoute || hasReceivers) && !("apiVersion" in v) && !("kind" in v);
290
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run bundle` in lexicon-prometheus.
4
+ */
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { writeBundleSpec } from "@intentius/chant/codegen/package";
8
+ import { generate, writeGeneratedFiles } from "./codegen/generate";
9
+ import { packageLexicon } from "./codegen/package";
10
+
11
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
12
+
13
+ writeGeneratedFiles(await generate({ verbose: true }));
14
+ const { spec, stats } = await packageLexicon({ verbose: true });
15
+ writeBundleSpec(spec, join(pkgDir, "dist"));
16
+
17
+ console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
package/src/pin.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The upstream releases this lexicon's hand-written types follow.
3
+ *
4
+ * Neither Prometheus nor Alertmanager publishes a machine-readable schema for
5
+ * its config files, so the types in `model.ts` are written against the Go
6
+ * structs of these releases (`prometheus/model/rulefmt`,
7
+ * `alertmanager/config`). Moving a version is a lexicon change like any
8
+ * other, released with this package.
9
+ */
10
+ export const PROMETHEUS_PIN = Object.freeze({
11
+ prometheus: Object.freeze({ source: "github.com/prometheus/prometheus", version: "v3.15.0" }),
12
+ alertmanager: Object.freeze({ source: "github.com/prometheus/alertmanager", version: "v0.34.1" }),
13
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isLexiconPlugin } from "@intentius/chant/lexicon";
3
+ import { prometheusPlugin } from "./plugin";
4
+ import { prometheusAuditCatalog } from "./lint/audit-catalog";
5
+
6
+ describe("prometheus plugin", () => {
7
+ it("is a valid LexiconPlugin", () => {
8
+ expect(isLexiconPlugin(prometheusPlugin)).toBe(true);
9
+ });
10
+
11
+ it("is named prometheus and serializes under the PROM prefix", () => {
12
+ expect(prometheusPlugin.name).toBe("prometheus");
13
+ expect(prometheusPlugin.serializer.name).toBe("prometheus");
14
+ expect(prometheusPlugin.serializer.rulePrefix).toBe("PROM");
15
+ expect(prometheusPlugin.serializer.extraRulePrefixes).toBeUndefined();
16
+ });
17
+
18
+ it("ships lint rules and post-synth checks, all under the PROM prefix", () => {
19
+ const ids = [...prometheusPlugin.lintRules!().map((r) => r.id), ...prometheusPlugin.postSynthChecks!().map((c) => c.id)];
20
+ expect(ids).toEqual([
21
+ "PROM001",
22
+ "PROM002",
23
+ "PROM003",
24
+ "PROM101",
25
+ "PROM102",
26
+ "PROM103",
27
+ "PROM104",
28
+ "PROM105",
29
+ "PROM106",
30
+ "PROM107",
31
+ "PROM201",
32
+ "PROM202",
33
+ "PROM203",
34
+ "PROM204",
35
+ "PROM205",
36
+ "PROM206",
37
+ "PROM207",
38
+ "PROM208",
39
+ "PROM209",
40
+ ]);
41
+ });
42
+
43
+ it("catalogues every rule and post-synth check for chant audit, and nothing else", () => {
44
+ const ids = [...prometheusPlugin.lintRules!().map((r) => r.id), ...prometheusPlugin.postSynthChecks!().map((c) => c.id)];
45
+ expect(Object.keys(prometheusAuditCatalog).sort()).toEqual([...ids].sort());
46
+ });
47
+
48
+ it("detects a rule file and an alertmanager.yml, and nothing else", () => {
49
+ expect(prometheusPlugin.detectTemplate!({ groups: [{ name: "a", rules: [] }] })).toBe(true);
50
+ expect(prometheusPlugin.detectTemplate!({ route: { receiver: "x" }, receivers: [{ name: "x" }] })).toBe(true);
51
+ expect(prometheusPlugin.detectTemplate!({ apiVersion: "v1", kind: "ConfigMap" })).toBe(false);
52
+ expect(prometheusPlugin.detectTemplate!({ receivers: { otlp: {} }, service: { pipelines: {} } })).toBe(false);
53
+ expect(prometheusPlugin.detectTemplate!({ groups: "nope" })).toBe(false);
54
+ });
55
+
56
+ it("loads its skills with content", () => {
57
+ const skills = prometheusPlugin.skills!();
58
+ expect(skills.map((s) => s.name)).toEqual(["chant-prometheus", "chant-prometheus-alertmanager", "chant-prometheus-kubernetes"]);
59
+ for (const s of skills) expect(s.content.length).toBeGreaterThan(100);
60
+ });
61
+
62
+ it("registers namespaced MCP contributions", () => {
63
+ expect(prometheusPlugin.mcpTools!().map((t) => t.name)).toEqual(["prometheus:diff"]);
64
+ expect(prometheusPlugin.mcpResources!().map((r) => r.uri)).toEqual(["prometheus:resource-catalog"]);
65
+ });
66
+
67
+ it("serves the catalog with its pins", async () => {
68
+ const body = JSON.parse(await prometheusPlugin.mcpResources!()[0].handler());
69
+ expect(body.pin.prometheus.version).toBe("v3.15.0");
70
+ expect(body.pin.alertmanager.version).toBe("v0.34.1");
71
+ expect(body.entities.map((e: { className: string }) => e.className)).toContain("RuleGroup");
72
+ });
73
+ });