Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions cmd/severityglyphs-check/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
86 changes: 86 additions & 0 deletions pkg/analyzers/severityglyphs/severityglyphs.go
Original file line number Diff line number Diff line change
@@ -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")
}
13 changes: 13 additions & 0 deletions pkg/analyzers/severityglyphs/severityglyphs_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
49 changes: 49 additions & 0 deletions pkg/analyzers/severityglyphs/testdata/src/example/example.go
Original file line number Diff line number Diff line change
@@ -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",
}
}
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions scripts/lint-fix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Loading