@intentius/chant-lexicon-prometheus 0.102.0 → 0.104.1

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 (124) hide show
  1. package/README.md +3 -1
  2. package/dist/collector-metrics.d.ts +58 -0
  3. package/dist/collector-metrics.d.ts.map +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/integrity.json +19 -4
  7. package/dist/lint/audit-catalog.d.ts +2 -0
  8. package/dist/lint/audit-catalog.d.ts.map +1 -1
  9. package/dist/lint/audit-lineage.d.ts +21 -0
  10. package/dist/lint/audit-lineage.d.ts.map +1 -1
  11. package/dist/lint/post-synth/index.d.ts.map +1 -1
  12. package/dist/lint/post-synth/prom-helpers.d.ts +9 -2
  13. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
  14. package/dist/lint/post-synth/prom211.d.ts +12 -0
  15. package/dist/lint/post-synth/prom211.d.ts.map +1 -0
  16. package/dist/lint/post-synth/prom212.d.ts +9 -0
  17. package/dist/lint/post-synth/prom212.d.ts.map +1 -0
  18. package/dist/lint/post-synth/prom213.d.ts +11 -0
  19. package/dist/lint/post-synth/prom213.d.ts.map +1 -0
  20. package/dist/lint/post-synth/prom214.d.ts +9 -0
  21. package/dist/lint/post-synth/prom214.d.ts.map +1 -0
  22. package/dist/lint/post-synth/prom215.d.ts +9 -0
  23. package/dist/lint/post-synth/prom215.d.ts.map +1 -0
  24. package/dist/lint/post-synth/prom216.d.ts +10 -0
  25. package/dist/lint/post-synth/prom216.d.ts.map +1 -0
  26. package/dist/lint/post-synth/prom217.d.ts +9 -0
  27. package/dist/lint/post-synth/prom217.d.ts.map +1 -0
  28. package/dist/lint/post-synth/prom218.d.ts +10 -0
  29. package/dist/lint/post-synth/prom218.d.ts.map +1 -0
  30. package/dist/lint/post-synth/prom219.d.ts +9 -0
  31. package/dist/lint/post-synth/prom219.d.ts.map +1 -0
  32. package/dist/lint/post-synth/prom220.d.ts +9 -0
  33. package/dist/lint/post-synth/prom220.d.ts.map +1 -0
  34. package/dist/lint/post-synth/prom221.d.ts +10 -0
  35. package/dist/lint/post-synth/prom221.d.ts.map +1 -0
  36. package/dist/lint/post-synth/prom222.d.ts +9 -0
  37. package/dist/lint/post-synth/prom222.d.ts.map +1 -0
  38. package/dist/lint/post-synth/prom223.d.ts +10 -0
  39. package/dist/lint/post-synth/prom223.d.ts.map +1 -0
  40. package/dist/lint/post-synth/prom224.d.ts +9 -0
  41. package/dist/lint/post-synth/prom224.d.ts.map +1 -0
  42. package/dist/lint/post-synth/prom301.d.ts +14 -0
  43. package/dist/lint/post-synth/prom301.d.ts.map +1 -0
  44. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  45. package/dist/manifest.json +1 -1
  46. package/dist/okf/index.md +15 -0
  47. package/dist/okf/rules/PROM211.md +11 -0
  48. package/dist/okf/rules/PROM212.md +11 -0
  49. package/dist/okf/rules/PROM213.md +11 -0
  50. package/dist/okf/rules/PROM214.md +11 -0
  51. package/dist/okf/rules/PROM215.md +11 -0
  52. package/dist/okf/rules/PROM216.md +11 -0
  53. package/dist/okf/rules/PROM217.md +11 -0
  54. package/dist/okf/rules/PROM218.md +11 -0
  55. package/dist/okf/rules/PROM219.md +11 -0
  56. package/dist/okf/rules/PROM220.md +11 -0
  57. package/dist/okf/rules/PROM221.md +11 -0
  58. package/dist/okf/rules/PROM222.md +11 -0
  59. package/dist/okf/rules/PROM223.md +11 -0
  60. package/dist/okf/rules/PROM224.md +11 -0
  61. package/dist/okf/rules/PROM301.md +11 -0
  62. package/dist/plugin.d.ts.map +1 -1
  63. package/dist/promql-analysis.d.ts +82 -0
  64. package/dist/promql-analysis.d.ts.map +1 -0
  65. package/dist/rules/literal-credential.ts +4 -0
  66. package/dist/rules/prom-helpers.ts +41 -2
  67. package/dist/rules/prom211.ts +21 -0
  68. package/dist/rules/prom212.ts +18 -0
  69. package/dist/rules/prom213.ts +20 -0
  70. package/dist/rules/prom214.ts +18 -0
  71. package/dist/rules/prom215.ts +18 -0
  72. package/dist/rules/prom216.ts +19 -0
  73. package/dist/rules/prom217.ts +18 -0
  74. package/dist/rules/prom218.ts +19 -0
  75. package/dist/rules/prom219.ts +18 -0
  76. package/dist/rules/prom220.ts +18 -0
  77. package/dist/rules/prom221.ts +19 -0
  78. package/dist/rules/prom222.ts +18 -0
  79. package/dist/rules/prom223.ts +19 -0
  80. package/dist/rules/prom224.ts +18 -0
  81. package/dist/rules/prom301.ts +23 -0
  82. package/dist/validate-config.d.ts +9 -3
  83. package/dist/validate-config.d.ts.map +1 -1
  84. package/dist/validate-integrations.d.ts +1 -0
  85. package/dist/validate-integrations.d.ts.map +1 -1
  86. package/dist/validate-security.d.ts +13 -0
  87. package/dist/validate-security.d.ts.map +1 -0
  88. package/package.json +2 -2
  89. package/src/collector-metrics.ts +262 -0
  90. package/src/composites/composites.test.ts +2 -5
  91. package/src/import/testdata/alertmanager-integrations.yml +5 -1
  92. package/src/index.ts +1 -0
  93. package/src/init-templates.test.ts +3 -1
  94. package/src/lint/audit-catalog.ts +102 -0
  95. package/src/lint/audit-lineage.test.ts +74 -0
  96. package/src/lint/audit-lineage.ts +28 -0
  97. package/src/lint/post-synth/checks.test.ts +257 -0
  98. package/src/lint/post-synth/index.ts +30 -0
  99. package/src/lint/post-synth/post-synth.test.ts +3 -1
  100. package/src/lint/post-synth/prom-helpers.ts +41 -2
  101. package/src/lint/post-synth/prom211.ts +21 -0
  102. package/src/lint/post-synth/prom212.ts +18 -0
  103. package/src/lint/post-synth/prom213.ts +20 -0
  104. package/src/lint/post-synth/prom214.ts +18 -0
  105. package/src/lint/post-synth/prom215.ts +18 -0
  106. package/src/lint/post-synth/prom216.ts +19 -0
  107. package/src/lint/post-synth/prom217.ts +18 -0
  108. package/src/lint/post-synth/prom218.ts +19 -0
  109. package/src/lint/post-synth/prom219.ts +18 -0
  110. package/src/lint/post-synth/prom220.ts +18 -0
  111. package/src/lint/post-synth/prom221.ts +19 -0
  112. package/src/lint/post-synth/prom222.ts +18 -0
  113. package/src/lint/post-synth/prom223.ts +19 -0
  114. package/src/lint/post-synth/prom224.ts +18 -0
  115. package/src/lint/post-synth/prom301.test.ts +157 -0
  116. package/src/lint/post-synth/prom301.ts +23 -0
  117. package/src/lint/rules/literal-credential.ts +4 -0
  118. package/src/lint/rules/rules.test.ts +16 -0
  119. package/src/plugin.test.ts +22 -0
  120. package/src/plugin.ts +12 -1
  121. package/src/promql-analysis.ts +365 -0
  122. package/src/validate-config.ts +274 -5
  123. package/src/validate-integrations.ts +1 -1
  124. package/src/validate-security.ts +143 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * PROM224: An inhibit rule matches one alert as source and target, with no equal
3
+ *
4
+ * With no equal, any alert matching the source mutes every alert matching the
5
+ * target. When one alert can match both, alerts of that kind mute each other.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { alertmanagerDiagnostics } from "./prom-helpers";
10
+
11
+ export const prom224: PostSynthCheck = {
12
+ id: "PROM224",
13
+ description: "An inhibit rule matches one alert as source and target, with no equal",
14
+
15
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
16
+ return alertmanagerDiagnostics(ctx, "PROM224");
17
+ },
18
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * PROM301 (#3364): rule selectors under a connector namespace against what
3
+ * the build's collector configs emit.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import type { Declarable } from "@intentius/chant/declarable";
7
+ import { makePostSynthCtx, makePostSynthCtxFromFiles } from "@intentius/chant-test-utils";
8
+ import { PrometheusExporter, ServiceGraphConnector, SpanMetricsConnector } from "@intentius/chant-lexicon-otel/components/index";
9
+ import { genAiMetrics, genAiPipeline, type GenAiMetricsOptions } from "@intentius/chant-lexicon-otel/genai";
10
+ import { collectorYaml } from "@intentius/chant-lexicon-otel/collector";
11
+ import { prom301 } from "./prom301";
12
+ import { emitYaml } from "../../build";
13
+ import { ruleGroupConfig } from "../../rules";
14
+ import { GenAiRules, type GenAiAlerting, type GenAiPrice } from "../../composites/genai";
15
+ import { Slo } from "../../composites/slo";
16
+ import type { RuleGroupConfig } from "../../model";
17
+
18
+ const entities = (...list: Declarable[]) => new Map(list.map((e, i) => [`e${i}`, e] as [string, Declarable]));
19
+
20
+ /** A rule file of one recording rule per expression. */
21
+ function rules(...exprs: string[]): string {
22
+ return JSON.stringify({ groups: [{ name: "g", rules: exprs.map((expr, i) => ({ record: `job:r${i}:rate5m`, expr })) }] });
23
+ }
24
+
25
+ /** PROM301's messages for a rule file, with the collector declared as otel entities in the same build. */
26
+ function found(text: string, ...collector: Declarable[]): string[] {
27
+ return prom301.check(makePostSynthCtx("prometheus", text, entities(...collector))).map((d) => d.message);
28
+ }
29
+
30
+ describe("PROM301", () => {
31
+ test("a rule reading traces_span_metrics_calls_total with no spanmetrics connector of that namespace is reported", () => {
32
+ const msgs = found(rules("sum by (service_name) (rate(traces_span_metrics_calls_total[5m]))"), new SpanMetricsConnector({ namespace: "shop" }));
33
+ expect(msgs).toEqual(["rule g/job:r0:rate5m reads traces_span_metrics_calls_total, which no collector config in the build emits under traces_span_metrics"]);
34
+ });
35
+
36
+ test("the names a declared connector emits pass, a misspelt one under its namespace does not", () => {
37
+ const shop = new SpanMetricsConnector({ namespace: "shop", histogram: { unit: "s" } });
38
+ expect(found(rules("sum by (service_name) (rate(shop_calls_total[5m]))", "histogram_quantile(0.9, sum by (le) (rate(shop_duration_seconds_bucket[5m])))"), shop)).toEqual([]);
39
+ expect(found(rules("sum(rate(shop_call_total[5m]))"), shop)).toEqual([
40
+ "rule g/job:r0:rate5m reads shop_call_total, which no collector config in the build emits under shop",
41
+ ]);
42
+ // A duration histogram in ms, read as seconds.
43
+ expect(found(rules("sum(rate(traces_span_metrics_duration_seconds_count[5m]))"), new SpanMetricsConnector({}))).toHaveLength(1);
44
+ });
45
+
46
+ test("a by (...) label that is not a declared dimension", () => {
47
+ const spans = new SpanMetricsConnector({ dimensions: [{ name: "http.route" }] });
48
+ expect(found(rules("sum by (service_name, http_route, job) (rate(traces_span_metrics_calls_total[5m]))"), spans)).toEqual([]);
49
+ expect(found(rules("sum by (http_method) (rate(traces_span_metrics_calls_total[5m]))"), spans)).toEqual([
50
+ "rule g/job:r0:rate5m groups traces_span_metrics_calls_total by http_method, which is not a dimension the collector config declares for it",
51
+ ]);
52
+ // le belongs to the _bucket series only.
53
+ expect(found(rules("sum by (le) (rate(traces_span_metrics_duration_milliseconds_count[5m]))"), spans)).toHaveLength(1);
54
+ // label_replace writes a label of its own, so the aggregation over it is not checked.
55
+ expect(found(rules('sum by (route) (label_replace(rate(traces_span_metrics_calls_total[5m]), "route", "$1", "http_route", "(.*)"))'), spans)).toEqual([]);
56
+ });
57
+
58
+ test("an exporter that copies every resource attribute leaves the labels open", () => {
59
+ const spans = new SpanMetricsConnector({});
60
+ const exporter = new PrometheusExporter({ endpoint: "0.0.0.0:8889", resource_to_telemetry_conversion: { enabled: true } });
61
+ expect(found(rules("sum by (k8s_namespace_name) (rate(traces_span_metrics_calls_total[5m]))"), spans, exporter)).toEqual([]);
62
+ });
63
+
64
+ test("the exporter's namespace moves every name", () => {
65
+ const spans = new SpanMetricsConnector({});
66
+ const exporter = new PrometheusExporter({ endpoint: "0.0.0.0:8889", namespace: "otel" });
67
+ expect(found(rules("sum(rate(otel_traces_span_metrics_calls_total[5m]))"), spans, exporter)).toEqual([]);
68
+ expect(found(rules("sum(rate(otel_traces_span_metrics_call_total[5m]))"), spans, exporter)).toHaveLength(1);
69
+ });
70
+
71
+ test("servicegraph names and labels", () => {
72
+ const graph = new ServiceGraphConnector({ dimensions: ["http.method"] });
73
+ expect(found(rules("sum by (client, server, client_http_method) (rate(traces_service_graph_request_total[5m]))"), graph)).toEqual([]);
74
+ expect(found(rules("sum by (client) (rate(traces_service_graph_request_duration_seconds_count[5m]))"), graph)).toHaveLength(1);
75
+ expect(found(rules("sum by (peer) (rate(traces_service_graph_request_failed_total[5m]))"), graph)).toHaveLength(1);
76
+ });
77
+
78
+ test("silent for names outside every namespace, and with no collector config in the build", () => {
79
+ expect(found(rules("sum by (job) (rate(http_requests_total[5m]))"), new SpanMetricsConnector({}))).toEqual([]);
80
+ expect(found(rules("sum by (nope) (rate(traces_span_metrics_calls_totl[5m]))"))).toEqual([]);
81
+ });
82
+
83
+ test("reads a collector config from the output too, as in a ConfigMap or a multi-lexicon context", () => {
84
+ const yaml = collectorYaml(genAiPipeline({ namespace: "shop" }));
85
+ const ctx = makePostSynthCtxFromFiles("prometheus", { "collector.yaml": yaml }, rules("sum(rate(traces_span_metrics_calls_total[5m]))"));
86
+ expect(prom301.check(ctx)).toHaveLength(1);
87
+ });
88
+ });
89
+
90
+ describe("PROM301 reads renamed connector types as the built-ins they name", () => {
91
+ const config = (connectors: string) => `receivers:
92
+ otlp: {}
93
+ exporters:
94
+ prometheus:
95
+ endpoint: 0.0.0.0:8889
96
+ connectors:
97
+ ${connectors}
98
+ service:
99
+ pipelines:
100
+ traces:
101
+ receivers: [otlp]
102
+ exporters: [debug]
103
+ `;
104
+ const ctx = (yaml: string, text: string) => makePostSynthCtxFromFiles("prometheus", { "collector.yaml": yaml }, text);
105
+
106
+ test("span_metrics, service_graph and signal_to_metrics", () => {
107
+ const yaml = config(` span_metrics/shop:
108
+ namespace: shop
109
+ service_graph: {}
110
+ signal_to_metrics/genai_client:
111
+ spans:
112
+ - name: gen_ai.client.operation.duration
113
+ unit: s
114
+ attributes: [{ key: gen_ai.operation.name }]
115
+ histogram: { buckets: [1] }
116
+ `);
117
+ const read = rules(
118
+ "sum by (service_name) (rate(shop_calls_total[5m]))",
119
+ "sum by (client) (rate(traces_service_graph_request_total[5m]))",
120
+ "sum by (gen_ai_operation_name, le) (rate(gen_ai_client_operation_duration_seconds_bucket[5m]))",
121
+ );
122
+ expect(prom301.check(ctx(yaml, read))).toEqual([]);
123
+ expect(prom301.check(ctx(yaml, rules("sum(rate(shop_call_total[5m]))", "sum(rate(gen_ai_client_operation_seconds_count[5m]))"))).map((d) => d.entity)).toEqual([
124
+ "g/job:r0:rate5m",
125
+ "g/job:r1:rate5m",
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("PROM301 on the lexicon's own rules", () => {
131
+ const groupYaml = (group: RuleGroupConfig) => emitYaml({ groups: [group] });
132
+ const prices: GenAiPrice[] = [{ provider: "anthropic", model: "m1", inputPerMTok: 3, outputPerMTok: 15, currency: "USD", source: "https://example.com", asOf: "2026-09-29" }];
133
+ const alerts: GenAiAlerting = { errorRatio: true, latency: true, toolErrorRatio: true, budgets: [{ amount: 5, currency: "USD", per: "hour" }] };
134
+
135
+ test.each([
136
+ ["spans", {}],
137
+ ["derived client metrics", { clientMetrics: "derive" }],
138
+ ["provider dimensions and a namespace", { providerDimensions: true, namespace: "ai" }],
139
+ ] as Array<[string, GenAiMetricsOptions]>)("GenAiRules over genAiPipeline with %s, with and without its alerts", (_label, options) => {
140
+ const collector = genAiPipeline({ ...options, metricExporters: [new PrometheusExporter({ endpoint: "0.0.0.0:8889" })] });
141
+ const genAi = genAiMetrics(options);
142
+ for (const props of [{ genAi, prices }, { genAi, prices, alerts }]) {
143
+ expect(found(groupYaml(ruleGroupConfig(GenAiRules(props).rules)), ...collector)).toEqual([]);
144
+ }
145
+ });
146
+
147
+ test("Slo over the default spanmetrics connector", () => {
148
+ const calls = "traces_span_metrics_calls_total";
149
+ const slo = Slo({
150
+ name: "checkout",
151
+ objective: 0.999,
152
+ window: "30d",
153
+ sli: { errors: `sum(rate(${calls}{span_name="checkout",status_code="STATUS_CODE_ERROR"}[{{window}}]))`, total: `sum(rate(${calls}{span_name="checkout"}[{{window}}]))` },
154
+ });
155
+ expect(found(groupYaml(ruleGroupConfig(slo.rules)), new SpanMetricsConnector({}))).toEqual([]);
156
+ });
157
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * PROM301: A rule reads a connector metric no collector in the build emits
3
+ *
4
+ * For each selector in a rule whose name falls under a namespace the build's
5
+ * spanmetrics, servicegraph or GenAI connectors own (spanmetrics' and
6
+ * servicegraph's defaults included), reports a name no collector config in
7
+ * the build emits, and a by (...) label that is not a dimension the config
8
+ * declares for the metrics aggregated. Silent for names outside those
9
+ * namespaces, and when the build has no collector config. See
10
+ * ../../collector-metrics.ts.
11
+ */
12
+
13
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
14
+ import { collectorMetricDiagnostics } from "./prom-helpers";
15
+
16
+ export const prom301: PostSynthCheck = {
17
+ id: "PROM301",
18
+ description: "A rule reads a connector metric no collector in the build emits",
19
+
20
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
21
+ return collectorMetricDiagnostics(ctx);
22
+ },
23
+ };
@@ -1,5 +1,6 @@
1
1
  import * as ts from "typescript";
2
2
  import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
3
+ import { SLACK_APP_URL } from "../../validate-integrations";
3
4
  import { calleeName, constInitializers, CREDENTIAL_CLASS, literalText, position, propertyName, resolveConst } from "./prom-ast";
4
5
 
5
6
  /**
@@ -89,6 +90,9 @@ export const literalCredentialRule: LintRule = {
89
90
  if (only && integration !== undefined && integration !== only) return;
90
91
  const text = literalText(init);
91
92
  if (text === undefined || text === "") return;
93
+ // A Slack bot posts to the public chat.postMessage endpoint with its token
94
+ // in http_config, and update_message needs exactly that api_url (PROM210).
95
+ if (key === "api_url" && text === SLACK_APP_URL) return;
92
96
  diagnostics.push({
93
97
  ruleId: "PROM001",
94
98
  severity: "error",
@@ -45,6 +45,22 @@ describe("PROM001 literal credential", () => {
45
45
  expect(diags).toEqual([]);
46
46
  });
47
47
 
48
+ test("accepts Slack's bot endpoint as api_url, which update_message needs", () => {
49
+ const diags = literalCredentialRule.check(
50
+ ctx(`
51
+ new Receiver({
52
+ name: "chat",
53
+ slack_configs: [{
54
+ api_url: "https://slack.com/api/chat.postMessage",
55
+ update_message: true,
56
+ http_config: { authorization: { credentials_file: "/etc/slack-bot-token" } },
57
+ }],
58
+ });
59
+ `),
60
+ );
61
+ expect(diags).toEqual([]);
62
+ });
63
+
48
64
  test("follows values lifted into named consts, shorthand props and spreads", () => {
49
65
  const diags = literalCredentialRule.check(
50
66
  ctx(`
@@ -38,6 +38,21 @@ describe("prometheus plugin", () => {
38
38
  "PROM208",
39
39
  "PROM209",
40
40
  "PROM210",
41
+ "PROM211",
42
+ "PROM212",
43
+ "PROM213",
44
+ "PROM214",
45
+ "PROM215",
46
+ "PROM216",
47
+ "PROM217",
48
+ "PROM218",
49
+ "PROM219",
50
+ "PROM220",
51
+ "PROM221",
52
+ "PROM222",
53
+ "PROM223",
54
+ "PROM224",
55
+ "PROM301",
41
56
  ]);
42
57
  });
43
58
 
@@ -46,6 +61,13 @@ describe("prometheus plugin", () => {
46
61
  expect(Object.keys(prometheusAuditCatalog).sort()).toEqual([...ids].sort());
47
62
  });
48
63
 
64
+ it("leaves PROM212 out of the recommended preset, and has it in all (#3363)", () => {
65
+ const presets = prometheusPlugin.lintPresets!();
66
+ expect(presets.all).toContain("PROM212");
67
+ expect(presets.recommended).not.toContain("PROM212");
68
+ expect(presets.all.filter((id) => !presets.recommended.includes(id))).toEqual(["PROM212"]);
69
+ });
70
+
49
71
  it("detects a rule file and an alertmanager.yml, and nothing else", () => {
50
72
  expect(prometheusPlugin.detectTemplate!({ groups: [{ name: "a", rules: [] }] })).toBe(true);
51
73
  expect(prometheusPlugin.detectTemplate!({ route: { receiver: "x" }, receivers: [{ name: "x" }] })).toBe(true);
package/src/plugin.ts CHANGED
@@ -5,7 +5,7 @@ import { createDiffTool } from "@intentius/chant/lexicon-plugin-helpers";
5
5
  import { prometheusSerializer } from "./serializer";
6
6
  import { rules } from "./lint/rules";
7
7
  import { postSynthChecks } from "./lint/post-synth";
8
- import { prometheusAuditCatalog } from "./lint/audit-catalog";
8
+ import { OPT_IN_CHECKS, prometheusAuditCatalog } from "./lint/audit-catalog";
9
9
  import { completions } from "./lsp/completions";
10
10
  import { hover } from "./lsp/hover";
11
11
  import { detectTemplate } from "./detect";
@@ -89,6 +89,17 @@ export const prometheusPlugin: LexiconPlugin = {
89
89
  return prometheusAuditCatalog;
90
90
  },
91
91
 
92
+ /**
93
+ * `recommended` (what `chant build` reports by default) is every check but
94
+ * the opt-in ones; `all` adds them. A project turns one on with
95
+ * `lint.presets: { prometheus: "all" }`, or by naming it in `lint.rules`
96
+ * (`{ PROM212: "warning" }`), which keeps a check whatever the preset.
97
+ */
98
+ lintPresets() {
99
+ const all = Object.keys(prometheusAuditCatalog);
100
+ return { recommended: all.filter((id) => !OPT_IN_CHECKS.has(id)), all };
101
+ },
102
+
92
103
  skills: prometheusSkills,
93
104
 
94
105
  composites() {
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Reading a parsed PromQL expression: the selectors it reads, the labels its
3
+ * result keeps, and whether it filters with a comparison. The rule-file
4
+ * checks PROM211-PROM219 (./validate-config.ts) are built on these.
5
+ *
6
+ * The tree is the one `checkPromql` (./promql.ts) parses with
7
+ * `@prometheus-io/lezer-promql`. Every function here returns nothing for an
8
+ * expression that does not parse; PROM104 reports that.
9
+ */
10
+
11
+ import { parser } from "@prometheus-io/lezer-promql";
12
+ import type { MatchOp } from "./matchers";
13
+
14
+ type SyntaxNode = ReturnType<typeof parser.parse>["topNode"];
15
+
16
+ /** The expression under the tree's `PromQL` root, or undefined when it does not parse. */
17
+ export function parsePromql(expr: string): SyntaxNode | undefined {
18
+ if (typeof expr !== "string" || expr.trim() === "") return undefined;
19
+ const tree = parser.parse(expr);
20
+ let broken = false;
21
+ tree.iterate({
22
+ enter: (n) => {
23
+ if (n.type.isError) broken = true;
24
+ return broken ? false : undefined;
25
+ },
26
+ });
27
+ return broken ? undefined : (tree.topNode.firstChild ?? undefined);
28
+ }
29
+
30
+ function text(n: SyntaxNode, src: string): string {
31
+ return src.slice(n.from, n.to);
32
+ }
33
+
34
+ /** The value of a PromQL string literal: double- or single-quoted with escapes, or a raw backtick string. */
35
+ export function stringValue(literal: string): string {
36
+ if (literal.startsWith("`")) return literal.slice(1, -1);
37
+ const body = literal.slice(1, -1);
38
+ try {
39
+ return JSON.parse(`"${literal.startsWith("'") ? body.replace(/\\'/g, "'").replace(/"/g, '\\"') : body}"`) as string;
40
+ } catch {
41
+ return body;
42
+ }
43
+ }
44
+
45
+ function children(n: SyntaxNode): SyntaxNode[] {
46
+ const out: SyntaxNode[] = [];
47
+ for (let c = n.firstChild; c; c = c.nextSibling) out.push(c);
48
+ return out;
49
+ }
50
+
51
+ /** The arguments of a function call or aggregation body, without the brackets and commas. */
52
+ function args(body: SyntaxNode | null): SyntaxNode[] {
53
+ return body ? children(body).filter((c) => c.name !== "(" && c.name !== ")" && c.name !== ",") : [];
54
+ }
55
+
56
+ function unwrap(n: SyntaxNode): SyntaxNode {
57
+ let cur = n;
58
+ while (cur.name === "ParenExpr" && cur.firstChild) cur = children(cur).find((c) => c.name !== "(" && c.name !== ")") ?? cur;
59
+ return cur;
60
+ }
61
+
62
+ export interface SelectorMatcher {
63
+ name: string;
64
+ op: MatchOp;
65
+ value: string;
66
+ }
67
+
68
+ /** One vector selector in an expression. */
69
+ export interface Selector {
70
+ /** The metric name, from the identifier or a `__name__="..."` matcher; undefined when neither names one. */
71
+ name?: string;
72
+ matchers: SelectorMatcher[];
73
+ /** The selector as written. */
74
+ text: string;
75
+ /** The functions it sits inside, innermost first, e.g. `["rate", "sum"]` for `sum(rate(x[5m]))`. */
76
+ within: string[];
77
+ }
78
+
79
+ function selectorOf(n: SyntaxNode, src: string, within: string[]): Selector {
80
+ const matchers: SelectorMatcher[] = [];
81
+ const list = n.getChild("LabelMatchers");
82
+ for (const c of list ? children(list) : []) {
83
+ if (c.name !== "UnquotedLabelMatcher" && c.name !== "QuotedLabelMatcher") continue;
84
+ const label = c.getChild("LabelName") ?? c.getChild("QuotedLabelName");
85
+ const op = c.getChild("MatchOp");
86
+ const value = c.getChild("StringLiteral");
87
+ if (!label || !op || !value) continue;
88
+ const name = label.name === "QuotedLabelName" ? stringValue(text(label, src)) : text(label, src);
89
+ matchers.push({ name, op: text(op, src) as MatchOp, value: stringValue(text(value, src)) });
90
+ }
91
+ const ident = n.getChild("Identifier");
92
+ const byName = matchers.find((m) => m.name === "__name__" && m.op === "=");
93
+ const name = ident ? text(ident, src) : byName?.value;
94
+ return { name, matchers, text: text(n, src), within };
95
+ }
96
+
97
+ /** The name of a function call or aggregation node, e.g. `rate` or `sum`. */
98
+ function callName(n: SyntaxNode, src: string): string | undefined {
99
+ if (n.name === "FunctionCall") {
100
+ const id = n.getChild("FunctionIdentifier");
101
+ return id ? text(id, src) : undefined;
102
+ }
103
+ if (n.name === "AggregateExpr") {
104
+ const op = n.getChild("AggregateOp");
105
+ return op ? text(op, src) : undefined;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Every vector selector in the expression, with the functions around it. */
111
+ export function selectors(expr: string): Selector[] {
112
+ const top = parsePromql(expr);
113
+ if (!top) return [];
114
+ const out: Selector[] = [];
115
+ const walk = (n: SyntaxNode, within: string[]) => {
116
+ if (n.name === "VectorSelector") {
117
+ out.push(selectorOf(n, expr, within));
118
+ return;
119
+ }
120
+ const fn = callName(n, expr);
121
+ const inner = fn ? [fn, ...within] : within;
122
+ for (const c of children(n)) walk(c, inner);
123
+ };
124
+ walk(top, []);
125
+ return out;
126
+ }
127
+
128
+ const COMPARISON = new Set(["Gtr", "Lss", "Gte", "Lte", "Eql", "Neq"]);
129
+
130
+ function binaryParts(n: SyntaxNode): { lhs: SyntaxNode; op: SyntaxNode; rhs: SyntaxNode; bool: boolean; modifier?: SyntaxNode } {
131
+ const parts = children(n);
132
+ return {
133
+ lhs: parts[0],
134
+ op: parts[1],
135
+ rhs: parts[parts.length - 1],
136
+ bool: parts.some((p) => p.name === "BoolModifier"),
137
+ modifier: parts.find((p) => p.name === "MatchingModifierClause"),
138
+ };
139
+ }
140
+
141
+ /** Functions whose result is itself a condition: they return a series only when something is missing. */
142
+ const CONDITION_FUNCTIONS = new Set(["absent", "absent_over_time"]);
143
+
144
+ /**
145
+ * Whether the expression filters its result: a comparison without `bool`,
146
+ * `absent()`/`absent_over_time()`, or `unless`, anywhere a series can pass
147
+ * through to the result. An expression without one returns every series it
148
+ * reads, so an alert on it fires for all of them (pint alerts/comparison).
149
+ * `or` needs a condition on both sides, since either side alone reaches the
150
+ * result.
151
+ */
152
+ export function hasCondition(expr: string): boolean | undefined {
153
+ const top = parsePromql(expr);
154
+ if (!top) return undefined;
155
+ const walk = (raw: SyntaxNode): boolean => {
156
+ const n = unwrap(raw);
157
+ switch (n.name) {
158
+ case "BinaryExpr": {
159
+ const { lhs, op, rhs, bool } = binaryParts(n);
160
+ if (COMPARISON.has(op.name)) return !bool || walk(lhs) || walk(rhs);
161
+ if (op.name === "Unless") return true;
162
+ if (op.name === "Or") return walk(lhs) && walk(rhs);
163
+ return walk(lhs) || walk(rhs);
164
+ }
165
+ case "FunctionCall": {
166
+ const fn = callName(n, expr) ?? "";
167
+ if (CONDITION_FUNCTIONS.has(fn)) return true;
168
+ return args(n.getChild("FunctionCallBody")).some(walk);
169
+ }
170
+ case "AggregateExpr":
171
+ return args(n.getChild("FunctionCallBody")).some(walk);
172
+ case "UnaryExpr":
173
+ case "OffsetExpr":
174
+ case "StepInvariantExpr":
175
+ return children(n).some(walk);
176
+ case "SubqueryExpr":
177
+ case "MatrixSelector":
178
+ return n.firstChild ? walk(n.firstChild) : false;
179
+ default:
180
+ return false;
181
+ }
182
+ };
183
+ return walk(top);
184
+ }
185
+
186
+ /**
187
+ * The labels an expression's result carries, as far as the expression says:
188
+ * `{ only }` when it keeps exactly these labels (`sum by (job)`), `{ except }`
189
+ * when it keeps every label but these (`sum without (instance)`), or
190
+ * undefined when it keeps whatever the series it reads carry.
191
+ */
192
+ export type LabelScope = { only: Set<string> } | { except: Set<string> };
193
+
194
+ const KEEPS_INPUT_LABELS = new Set(["topk", "bottomk", "limitk", "limit_ratio"]);
195
+ const NO_LABELS = new Set(["vector", "time", "scalar", "pi"]);
196
+
197
+ function grouping(mod: SyntaxNode | null, src: string): { by: boolean; labels: string[] } | undefined {
198
+ if (!mod) return undefined;
199
+ const labels = (mod.getChild("GroupingLabels")?.getChildren("LabelName") ?? []).map((l) => text(l, src));
200
+ return { by: !!mod.getChild("By"), labels };
201
+ }
202
+
203
+ function minus(scope: LabelScope | undefined, drop: string[]): LabelScope {
204
+ if (!scope) return { except: new Set(drop) };
205
+ if ("only" in scope) return { only: new Set([...scope.only].filter((l) => !drop.includes(l))) };
206
+ return { except: new Set([...scope.except, ...drop]) };
207
+ }
208
+
209
+ function plus(scope: LabelScope | undefined, add: string[]): LabelScope | undefined {
210
+ if (!scope) return undefined;
211
+ if ("only" in scope) return { only: new Set([...scope.only, ...add]) };
212
+ return { except: new Set([...scope.except].filter((l) => !add.includes(l))) };
213
+ }
214
+
215
+ /** The labels the expression's result keeps; see {@link LabelScope}. */
216
+ export function resultLabels(expr: string): LabelScope | undefined {
217
+ const top = parsePromql(expr);
218
+ if (!top) return undefined;
219
+ const walk = (raw: SyntaxNode): LabelScope | undefined => {
220
+ const n = unwrap(raw);
221
+ switch (n.name) {
222
+ case "NumberDurationLiteral":
223
+ case "StringLiteral":
224
+ return { only: new Set() };
225
+ case "AggregateExpr": {
226
+ const op = callName(n, expr) ?? "";
227
+ const body = args(n.getChild("FunctionCallBody"));
228
+ const last = body[body.length - 1];
229
+ const inner = last ? walk(last) : undefined;
230
+ if (KEEPS_INPUT_LABELS.has(op)) return inner;
231
+ if (op === "count_values") return undefined;
232
+ const g = grouping(n.getChild("AggregateModifier"), expr);
233
+ if (!g) return { only: new Set() };
234
+ return g.by ? { only: new Set(g.labels) } : minus(inner, g.labels);
235
+ }
236
+ case "FunctionCall": {
237
+ const fn = callName(n, expr) ?? "";
238
+ if (NO_LABELS.has(fn)) return { only: new Set() };
239
+ const a = args(n.getChild("FunctionCallBody"));
240
+ if (fn === "histogram_quantile" || fn === "histogram_fraction") return minus(walk(a[a.length - 1]), ["le"]);
241
+ if (fn === "label_replace" || fn === "label_join") {
242
+ const dst = a[1]?.name === "StringLiteral" ? stringValue(text(a[1], expr)) : undefined;
243
+ return dst === undefined ? undefined : plus(walk(a[0]), [dst]);
244
+ }
245
+ if (CONDITION_FUNCTIONS.has(fn)) return undefined;
246
+ const vector = a.find((x) => x.name !== "NumberDurationLiteral" && x.name !== "StringLiteral");
247
+ return vector ? walk(vector) : { only: new Set() };
248
+ }
249
+ case "BinaryExpr": {
250
+ const { lhs, op, rhs, modifier } = binaryParts(n);
251
+ const l = walk(lhs);
252
+ const r = walk(rhs);
253
+ const lScalar = unwrap(lhs).name === "NumberDurationLiteral";
254
+ const rScalar = unwrap(rhs).name === "NumberDurationLiteral";
255
+ if (lScalar && !rScalar) return r;
256
+ if (rScalar) return l;
257
+ if (op.name === "And" || op.name === "Unless") return l;
258
+ if (op.name === "Or") {
259
+ if (l && r && "only" in l && "only" in r) return { only: new Set([...l.only, ...r.only]) };
260
+ return undefined;
261
+ }
262
+ if (!modifier) return l;
263
+ const on = !!modifier.getChild("On");
264
+ const groupLabels = modifier.getChildren("GroupingLabels").map((g) => g.getChildren("LabelName").map((x) => text(x, expr)));
265
+ const matching = groupLabels[0] ?? [];
266
+ const extra = groupLabels[1] ?? [];
267
+ if (modifier.getChild("GroupLeft")) return plus(l, extra);
268
+ if (modifier.getChild("GroupRight")) return plus(r, extra);
269
+ // One-to-one: `on` keeps only the matching labels, `ignoring` drops them.
270
+ return on ? { only: new Set(matching.filter((x) => !l || ("only" in l ? l.only.has(x) : !l.except.has(x)))) } : minus(l, matching);
271
+ }
272
+ case "UnaryExpr":
273
+ case "OffsetExpr":
274
+ case "StepInvariantExpr":
275
+ return walk(children(n).find((c) => c.name !== "UnaryOp" && c.name !== "Offset" && c.name !== "OffsetDurationExpr") ?? n.firstChild!);
276
+ default:
277
+ return undefined;
278
+ }
279
+ };
280
+ return walk(top);
281
+ }
282
+
283
+ /** Whether a label survives a scope. An undefined scope keeps every label. */
284
+ export function keepsLabel(scope: LabelScope | undefined, label: string): boolean {
285
+ if (!scope) return true;
286
+ return "only" in scope ? scope.only.has(label) : !scope.except.has(label);
287
+ }
288
+
289
+ /** One `histogram_quantile` call and what is wrong with its histogram argument. */
290
+ export interface HistogramProblem {
291
+ call: string;
292
+ problem: "no-bucket" | "no-le";
293
+ }
294
+
295
+ /** `histogram_quantile` calls over a selector with no `_bucket` in its name, or an aggregation that drops `le`. */
296
+ export function histogramProblems(expr: string): HistogramProblem[] {
297
+ const top = parsePromql(expr);
298
+ if (!top) return [];
299
+ const out: HistogramProblem[] = [];
300
+ const walk = (n: SyntaxNode) => {
301
+ if (n.name === "FunctionCall" && callName(n, expr) === "histogram_quantile") {
302
+ const a = args(n.getChild("FunctionCallBody"));
303
+ const histogram = a[1];
304
+ if (histogram) {
305
+ const call = text(n, expr);
306
+ const names = selectors(text(histogram, expr)).flatMap((s) => (s.name ? [s.name] : []));
307
+ if (names.some((x) => !x.includes("_bucket"))) out.push({ call, problem: "no-bucket" });
308
+ else if (!keepsLabel(resultLabels(text(histogram, expr)), "le")) out.push({ call, problem: "no-le" });
309
+ }
310
+ }
311
+ for (const c of children(n)) walk(c);
312
+ };
313
+ walk(top);
314
+ return out;
315
+ }
316
+
317
+ const COUNTER_FUNCTIONS = new Set(["rate", "irate", "increase"]);
318
+ const COUNTER_SUFFIX = /_(total|count|sum|bucket)$/;
319
+
320
+ /** `rate`, `irate` or `increase` read straight from a named selector whose name does not end in a counter suffix. */
321
+ export function nonCounterRates(expr: string): Array<{ fn: string; name: string }> {
322
+ return selectors(expr).flatMap((s) => {
323
+ const fn = s.within[0];
324
+ if (!fn || !COUNTER_FUNCTIONS.has(fn) || !s.name || COUNTER_SUFFIX.test(s.name)) return [];
325
+ return [{ fn, name: s.name }];
326
+ });
327
+ }
328
+
329
+ const REGEX_META = /[.+*?()[\]{}|\\^$]/;
330
+
331
+ /** A regex matcher that could be an equality matcher, or that carries anchors Prometheus already adds. */
332
+ export interface RegexProblem {
333
+ matcher: string;
334
+ problem: "literal" | "anchored";
335
+ }
336
+
337
+ /** The `=~` and `!~` matchers in an expression that need no regex, or that are anchored (pint promql/regexp). */
338
+ export function regexProblems(expr: string): RegexProblem[] {
339
+ const out: RegexProblem[] = [];
340
+ for (const s of selectors(expr)) {
341
+ for (const m of s.matchers) {
342
+ if (m.op !== "=~" && m.op !== "!~") continue;
343
+ const shown = `${m.name}${m.op}${JSON.stringify(m.value)}`;
344
+ if (!REGEX_META.test(m.value)) out.push({ matcher: shown, problem: "literal" });
345
+ else if (m.value.startsWith("^") || (m.value.endsWith("$") && !m.value.endsWith("\\$"))) out.push({ matcher: shown, problem: "anchored" });
346
+ }
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Whether every selector in the expression is read through a `*_over_time` function, so the expression already spans a window. */
352
+ export function readsOnlyOverTime(expr: string): boolean {
353
+ const all = selectors(expr);
354
+ return all.length > 0 && all.every((s) => s.within.some((fn) => fn.endsWith("_over_time")));
355
+ }
356
+
357
+ /** Whether the expression is two conditions joined by `and`: the multi-window form, where the short window does what `for` would. */
358
+ export function isMultiWindow(expr: string): boolean {
359
+ const top = parsePromql(expr);
360
+ if (!top) return false;
361
+ const n = unwrap(top);
362
+ if (n.name !== "BinaryExpr") return false;
363
+ const { lhs, op, rhs } = binaryParts(n);
364
+ return op.name === "And" && hasCondition(text(lhs, expr)) === true && hasCondition(text(rhs, expr)) === true;
365
+ }