From 7a725635b820a1b04dff77472179eefb68faf0a3 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Wed, 26 Aug 2026 13:36:29 +0800 Subject: [PATCH 1/2] feat(lint): add severityglyphs analyzer to keep the severity vocabulary in pkg/glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flags string literals containing a severity glyph (🚨 β›” ❌ ⚠️ ℹ️) in non-test source files and points at the pkg/glyph constant to use instead. The analyzer inspects decoded string values, so escape-spelled glyphs are caught, and it matches base codepoints so variation-selector variants are too. Test files are skipped: rendered-output assertions deliberately pin the literal glyphs so they break on vocabulary drift. Apply-state glyphs are a separate vocabulary and are not flagged. Wired into the pre-commit hook over staged packages (default + integration tags), excluding pkg/glyph and the analyzer's own glyph table. Co-Authored-By: Claude Fable 5 --- cmd/severityglyphs-check/main.go | 20 +++++ .../severityglyphs/severityglyphs.go | 86 +++++++++++++++++++ .../severityglyphs/severityglyphs_test.go | 13 +++ .../testdata/src/example/example.go | 49 +++++++++++ .../testdata/src/example/example_test.go | 11 +++ scripts/lint-fix.sh | 38 ++++++++ 6 files changed, 217 insertions(+) create mode 100644 cmd/severityglyphs-check/main.go create mode 100644 pkg/analyzers/severityglyphs/severityglyphs.go create mode 100644 pkg/analyzers/severityglyphs/severityglyphs_test.go create mode 100644 pkg/analyzers/severityglyphs/testdata/src/example/example.go create mode 100644 pkg/analyzers/severityglyphs/testdata/src/example/example_test.go diff --git a/cmd/severityglyphs-check/main.go b/cmd/severityglyphs-check/main.go new file mode 100644 index 000000000..7408b2ed4 --- /dev/null +++ b/cmd/severityglyphs-check/main.go @@ -0,0 +1,20 @@ +// Command severityglyphs-check runs the severityglyphs analyzer as a +// standalone tool. +// +// Usage: +// +// go run ./cmd/severityglyphs-check ./pkg/... +// +// The analyzer reports on every package it is given. The caller is +// responsible for excluding ./pkg/glyph (the severity vocabulary's +// legitimate home for the glyph literals). +package main + +import ( + "github.com/block/schemabot/pkg/analyzers/severityglyphs" + "golang.org/x/tools/go/analysis/singlechecker" +) + +func main() { + singlechecker.Main(severityglyphs.Analyzer) +} diff --git a/pkg/analyzers/severityglyphs/severityglyphs.go b/pkg/analyzers/severityglyphs/severityglyphs.go new file mode 100644 index 000000000..2a0af02f6 --- /dev/null +++ b/pkg/analyzers/severityglyphs/severityglyphs.go @@ -0,0 +1,86 @@ +// Package severityglyphs provides a go/analysis analyzer that flags severity +// glyph literals (🚨 β›” ❌ ⚠️ ℹ️) in non-test source files. The project +// convention is that the severity vocabulary lives in pkg/glyph β€” one glyph +// per meaning, one meaning per glyph β€” and every rendering site references +// the named constant, so the vocabulary cannot drift surface by surface. +// +// The analyzer inspects decoded string values, so escape-spelled glyphs +// (e.g. "❌") are caught the same as literal ones. It matches on the +// base codepoints (⚠ U+26A0, β„Ή U+2139), so variation-selector forms are +// caught too. Apply-state glyphs (🚫 ⏹️ ⏸ ⏳ ↩️ πŸ” βœ…) are a separate +// vocabulary owned by pkg/presentation and are not flagged. +// +// The analyzer reports on every matching string literal in every package it +// is invoked against β€” it does not filter by import path. Callers (Makefile, +// CI, pre-commit script) are responsible for passing only the package set +// where the rule should apply: everything except ./pkg/glyph, the vocabulary's +// legitimate home. +package severityglyphs + +import ( + "go/ast" + "go/token" + "strconv" + "strings" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/passes/inspect" + "golang.org/x/tools/go/ast/inspector" +) + +// severityGlyphs pairs each severity glyph's base codepoint with the +// pkg/glyph constant that rendering code must use instead. Matching on the +// base codepoint (not the emoji-presentation sequence) also catches literals +// that omit or add the variation selector. +var severityGlyphs = []struct { + glyph string + constant string +}{ + {"🚨", "glyph.Escalation"}, + {"β›”", "glyph.Refused"}, + {"❌", "glyph.Failed"}, + {"⚠", "glyph.Attention"}, + {"β„Ή", "glyph.Info"}, +} + +// Analyzer flags string literals containing a severity glyph in non-test +// source files. Test files are skipped because assertions on rendered output +// deliberately pin the literal glyphs β€” they must break when the vocabulary +// drifts. +var Analyzer = &analysis.Analyzer{ + Name: "severityglyphs", + Doc: "flags severity glyph literals (🚨 β›” ❌ ⚠️ ℹ️) outside pkg/glyph; use the named pkg/glyph constants", + Requires: []*analysis.Analyzer{inspect.Analyzer}, + Run: run, +} + +func run(pass *analysis.Pass) (any, error) { + insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) + + nodeFilter := []ast.Node{(*ast.BasicLit)(nil)} + + insp.Preorder(nodeFilter, func(n ast.Node) { + lit := n.(*ast.BasicLit) + if lit.Kind != token.STRING { + return + } + if isTestFile(pass, lit.Pos()) { + return + } + val, err := strconv.Unquote(lit.Value) + if err != nil { + return + } + for _, sg := range severityGlyphs { + if strings.Contains(val, sg.glyph) { + pass.Reportf(lit.Pos(), "severity glyph %s in string literal β€” use %s from pkg/glyph", sg.glyph, sg.constant) + } + } + }) + + return nil, nil +} + +func isTestFile(pass *analysis.Pass, pos token.Pos) bool { + return strings.HasSuffix(pass.Fset.Position(pos).Filename, "_test.go") +} diff --git a/pkg/analyzers/severityglyphs/severityglyphs_test.go b/pkg/analyzers/severityglyphs/severityglyphs_test.go new file mode 100644 index 000000000..810e570de --- /dev/null +++ b/pkg/analyzers/severityglyphs/severityglyphs_test.go @@ -0,0 +1,13 @@ +package severityglyphs_test + +import ( + "testing" + + "github.com/block/schemabot/pkg/analyzers/severityglyphs" + "golang.org/x/tools/go/analysis/analysistest" +) + +func TestAnalyzer(t *testing.T) { + testdata := analysistest.TestData() + analysistest.Run(t, testdata, severityglyphs.Analyzer, "example") +} diff --git a/pkg/analyzers/severityglyphs/testdata/src/example/example.go b/pkg/analyzers/severityglyphs/testdata/src/example/example.go new file mode 100644 index 000000000..07949054e --- /dev/null +++ b/pkg/analyzers/severityglyphs/testdata/src/example/example.go @@ -0,0 +1,49 @@ +package example + +import "fmt" + +// The fixtures are exercised by the analyzer, not by callers. +var _ = []any{bad, badEscape, badBare, badRaw, badInfo, goodConcat, goodStateGlyphs} + +// Bad: literal severity glyph inline. +func bad() string { + return "β›” Apply blocked: 3 unsafe change(s) detected" // want `severity glyph β›” in string literal β€” use glyph.Refused from pkg/glyph` +} + +// Bad: escape-spelled glyph β€” the analyzer sees the decoded value. +func badEscape(table string) string { + return fmt.Sprintf("**`%s`**: \u274c Failed", table) // want `severity glyph ❌ in string literal β€” use glyph.Failed from pkg/glyph` +} + +// Bad: base codepoint without the variation selector. +func badBare() string { + return "⚠ unsafe change awaiting consent" // want `severity glyph ⚠ in string literal β€” use glyph.Attention from pkg/glyph` +} + +// Bad: raw string literal. +func badRaw() string { + return `🚨 destructive consent in effect` // want `severity glyph 🚨 in string literal β€” use glyph.Escalation from pkg/glyph` +} + +// Bad: info glyph with variation selector. +func badInfo() string { + return "ℹ️ nothing to do" // want `severity glyph β„Ή in string literal β€” use glyph.Info from pkg/glyph` +} + +// Good: the vocabulary constant (glyph.Refused at real call sites) +// concatenated into the message; the string parts carry no glyphs. +func goodConcat(refused string) string { + return refused + " Apply blocked: 3 unsafe change(s) detected" +} + +// Good: apply-state glyphs are a separate vocabulary and are not flagged. +func goodStateGlyphs() []string { + return []string{ + "🚫 Cancelled", + "⏹️ Stopped", + "⏸️ Defer Cutover", + "⏳ Revert window open", + "↩️ Reverted", + "βœ… Completed", + } +} diff --git a/pkg/analyzers/severityglyphs/testdata/src/example/example_test.go b/pkg/analyzers/severityglyphs/testdata/src/example/example_test.go new file mode 100644 index 000000000..328476ab5 --- /dev/null +++ b/pkg/analyzers/severityglyphs/testdata/src/example/example_test.go @@ -0,0 +1,11 @@ +package example + +// _test.go files are deliberately not flagged β€” assertions on rendered output +// legitimately pin the literal glyphs so they break when the vocabulary +// drifts. + +func unusedTestFixture() string { + return "β›” Apply blocked: 3 unsafe change(s) detected" +} + +var _ = unusedTestFixture diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh index 4a4fd6caf..68a203b1b 100755 --- a/scripts/lint-fix.sh +++ b/scripts/lint-fix.sh @@ -202,4 +202,42 @@ if [ -n "$PKG_FILES" ]; then fi fi +# Run severityglyphs analyzer on staged packages to flag severity glyph +# literals that should reference the pkg/glyph constants instead. The analyzer +# skips test files itself (rendered-output assertions deliberately pin literal +# glyphs); pkg/glyph (the vocabulary's home) and the analyzer's own glyph +# table are excluded here. Mirrors the closeandlog build-tag matrix. +run_severityglyphs() { + local build_tags="$1" + shift + local patterns=("$@") + local tag_flag="" + + if [ -n "$build_tags" ]; then + tag_flag="-tags=$build_tags" + fi + + # `grep -v` exits non-zero (and would trip `set -e`) when every package + # is filtered out, so suppress that exit and skip the run instead. + local packages + packages=$(go list $tag_flag "${patterns[@]}" 2>/dev/null | grep -v '/pkg/glyph$' | grep -v '/pkg/analyzers/severityglyphs' | grep -v '/testdata/' || true) + if [ -z "$packages" ]; then + return 0 + fi + + if ! go run ./cmd/severityglyphs-check $tag_flag $packages 2>&1; then + echo "" + echo "severityglyphs: use the pkg/glyph constants (glyph.Escalation, glyph.Refused," + echo "glyph.Failed, glyph.Attention, glyph.Info) instead of literal severity glyphs." + exit 1 + fi +} + +if [ -n "$PKG_FILES" ]; then + SEVERITYGLYPHS_PKGS=$(echo "$PKG_FILES" | xargs -n1 dirname | sort -u | sed 's|^|./|' | sed 's|$|/...|') + echo "Running severityglyphs analyzer..." + run_severityglyphs "" $SEVERITYGLYPHS_PKGS + run_severityglyphs "integration" $SEVERITYGLYPHS_PKGS +fi + echo "All lint checks passed!" From f857ff4591f92e3f6adf32a38ed720bae5674b39 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Mon, 31 Aug 2026 20:03:36 -0400 Subject: [PATCH 2/2] fix(lint): fail the severityglyphs hook closed when go list errors A pre-commit enforcement check that silently skips on a module error vouches for packages it never inspected. Surface go list failures and block the commit; keep the quiet skip only for the everything-filtered case, and say so. Align the analyzer Doc with the caller-owned package set contract. Co-Authored-By: Claude Fable 5 --- pkg/analyzers/severityglyphs/severityglyphs.go | 2 +- scripts/lint-fix.sh | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/analyzers/severityglyphs/severityglyphs.go b/pkg/analyzers/severityglyphs/severityglyphs.go index 2a0af02f6..86b1eaeaf 100644 --- a/pkg/analyzers/severityglyphs/severityglyphs.go +++ b/pkg/analyzers/severityglyphs/severityglyphs.go @@ -49,7 +49,7 @@ var severityGlyphs = []struct { // drifts. var Analyzer = &analysis.Analyzer{ Name: "severityglyphs", - Doc: "flags severity glyph literals (🚨 β›” ❌ ⚠️ ℹ️) outside pkg/glyph; use the named pkg/glyph constants", + Doc: "flags severity glyph literals (🚨 β›” ❌ ⚠️ ℹ️) in non-test files; use the named pkg/glyph constants (callers exclude pkg/glyph itself from the package set)", Requires: []*analysis.Analyzer{inspect.Analyzer}, Run: run, } diff --git a/scripts/lint-fix.sh b/scripts/lint-fix.sh index 68a203b1b..03e51f490 100755 --- a/scripts/lint-fix.sh +++ b/scripts/lint-fix.sh @@ -217,11 +217,20 @@ run_severityglyphs() { tag_flag="-tags=$build_tags" fi + # Fail closed: if go list can't resolve the staged packages, the analyzer + # can't vouch for them, so block the commit rather than silently skipping. + local listed + if ! listed=$(go list $tag_flag "${patterns[@]}"); then + echo "severityglyphs: go list failed for staged packages; commit blocked until it resolves." + exit 1 + fi + # `grep -v` exits non-zero (and would trip `set -e`) when every package # is filtered out, so suppress that exit and skip the run instead. local packages - packages=$(go list $tag_flag "${patterns[@]}" 2>/dev/null | grep -v '/pkg/glyph$' | grep -v '/pkg/analyzers/severityglyphs' | grep -v '/testdata/' || true) + packages=$(echo "$listed" | grep -v '/pkg/glyph$' | grep -v '/pkg/analyzers/severityglyphs' | grep -v '/testdata/' || true) if [ -z "$packages" ]; then + echo "severityglyphs: all staged packages are excluded from the check; skipping." return 0 fi