From 7bc20e0e525450a8be05f024f002b03530d1b08f Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Tue, 2 Jun 2026 11:16:37 +0200 Subject: [PATCH] feat(prometheus): ast-grep YAML for counter-total-suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the mechanical enforcement gap from PR #8 (which deferred the YAML to a focused follow-up after the PR #4 lesson about ast-grep 0.43.0 traversal complexity). Rule shape (smoke-tested against fixture files with 4 distinct edge cases): pattern: context: 'prometheus.CounterOpts{Name: $V, $$$}' selector: 'keyed_element' inside: pattern: 'prometheus.CounterOpts{$$$}' stopBy: end constraints: V: not: regex: '_total"$' Key syntax discoveries (worth propagating to docs/ast-grep-rule-writing-guide.md): - pattern.context + selector lets you match a structural sub-node inside a parsed context. Required because bare 'Name: $X' parses as a labeled_statement, not a keyed_element. - inside with stopBy: end anchors on the enclosing literal so GaugeOpts / HistogramOpts / SummaryOpts (which share the Name field shape) don't false-flag. Confirmed on the fixture: zero FPs. - constraints at rule-top-level (sibling to 'rule:'), with 'not.regex' inside the metavariable spec. The standalone 'pattern + constraints' flat form does not support 'not' as a child of constraints.X. Edge cases verified: - NewCounterVec + NewCounter both share CounterOpts struct → both match - Name field position-agnostic (first / middle / last all flag if missing _total) - GaugeOpts / HistogramOpts / SummaryOpts with Name: 'x' do NOT flag - _test.go, main.go, vendor/, mocks/ ignored Doc updated: counter-total-suffix Enforcement field now points at the YAML instead of 'judgment (ast-grep follow-up)'. Index regenerated: the entry's enforcement field now reads 'rules/go/counter-total-suffix.yml'. --- docs/go-prometheus-metrics-guide.md | 2 +- rules/go/counter-total-suffix.yml | 34 +++++++++++++++++++++++++++++ rules/index.json | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 rules/go/counter-total-suffix.yml diff --git a/docs/go-prometheus-metrics-guide.md b/docs/go-prometheus-metrics-guide.md index c41bd80..0716745 100644 --- a/docs/go-prometheus-metrics-guide.md +++ b/docs/go-prometheus-metrics-guide.md @@ -207,7 +207,7 @@ Guidelines: **Owner**: go-metrics-assistant **Applies when**: a `prometheus.CounterOpts` struct literal sets a `Name:` field whose string value does not end with `_total`. -**Enforcement**: judgment (ast-grep follow-up) +**Enforcement**: `rules/go/counter-total-suffix.yml` **Why**: Prometheus naming convention; newer `client_golang` versions enforce this at registration time (panic). Counters without `_total` also fail the OpenMetrics spec and confuse Grafana auto-completion. #### Bad diff --git a/rules/go/counter-total-suffix.yml b/rules/go/counter-total-suffix.yml new file mode 100644 index 0000000..cf4955b --- /dev/null +++ b/rules/go/counter-total-suffix.yml @@ -0,0 +1,34 @@ +id: go-prometheus/counter-total-suffix +language: go +severity: error +message: | + Counter metric Name must end with "_total". + Prometheus naming convention; newer client_golang versions panic at registration without it. + See docs/go-prometheus-metrics-guide.md (RULE go-prometheus/counter-total-suffix). +rule: + # ast-grep 0.43.0 shape (verified against /tmp test fixtures): + # - pattern.context binds a structural match anchored on prometheus.CounterOpts + # so the `selector: keyed_element` only fires inside Counter literals. + # - inside.pattern + stopBy: end re-anchors on CounterOpts to avoid false + # positives on GaugeOpts / HistogramOpts / SummaryOpts which share the + # `Name: "..."` field shape. + # - constraints.V.not.regex rejects values ending in `_total"` (the closing + # quote of an interpreted_string_literal). Name field position-agnostic — + # works whether Name comes first, middle, or last in the struct literal. + pattern: + context: 'prometheus.CounterOpts{Name: $V, $$$}' + selector: 'keyed_element' + inside: + pattern: 'prometheus.CounterOpts{$$$}' + stopBy: end +constraints: + V: + not: + regex: '_total"$' +ignores: + - "main.go" + - "**/main.go" + - "**/*_test.go" + - "vendor/**" + - "**/vendor/**" + - "**/mocks/**" diff --git a/rules/index.json b/rules/index.json index 5b67976..5e2d292 100644 --- a/rules/index.json +++ b/rules/index.json @@ -210,7 +210,7 @@ "anchor": "go-prometheus/counter-total-suffix", "applies_when": "a `prometheus.CounterOpts` struct literal sets a `Name:` field whose string value does not end with `_total`.", "doc_path": "docs/go-prometheus-metrics-guide.md", - "enforcement": "judgment (ast-grep follow-up)", + "enforcement": "`rules/go/counter-total-suffix.yml`", "id": "go-prometheus/counter-total-suffix", "level": "MUST", "owner": "go-metrics-assistant"