mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-07-22 13:49:40 +00:00
Telegram rewrote the forwardMessages and copyMessages return sentence from "an array of MessageId" to "an Array of MessageId". The pattern that handles the "... of the sent messages is returned" shape matched lowercase only, so both methods fell back to bool and the weekly regen job failed at the audit step. Add approved_any_locations to overrides.json so a union that renders as `any` by design can be approved as data instead of a code change. Approve InputRichMessageMedia.Media, a five-variant InputMedia union added in Bot API 10.2 that has no declared parent type. Make the drift test compare HEAD against itself. It compared the working tree against HEAD, so it failed whenever a regen run added a method.
293 lines
7.7 KiB
Go
293 lines
7.7 KiB
Go
// Command audit reports IR-level codegen fallbacks and signature drift.
|
|
//
|
|
// Usage:
|
|
//
|
|
// audit -ir <path> (default internal/spec/api.json)
|
|
// audit -overrides <path> (default internal/spec/overrides.json)
|
|
// audit -drift (compare against -against ref's IR; off by default)
|
|
// audit -against <ref> (git ref to diff drift against; default HEAD~1)
|
|
//
|
|
// Exit codes:
|
|
//
|
|
// 0 — clean
|
|
// 1 — unaccounted bool fallbacks or any-typed fields
|
|
// 2 — drift detected (signature changed)
|
|
// 3 — invalid IR or overrides
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"github.com/goccy/go-json"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/go-telegram/internal/spec"
|
|
)
|
|
|
|
const (
|
|
exitClean = 0
|
|
exitFallback = 1
|
|
exitDrift = 2
|
|
exitInvalid = 3
|
|
)
|
|
|
|
func main() {
|
|
irPath := flag.String("ir", "internal/spec/api.json", "path to IR JSON")
|
|
ovPath := flag.String("overrides", "internal/spec/overrides.json", "path to overrides JSON")
|
|
checkDrift := flag.Bool("drift", false, "compare against -against ref's IR for signature changes")
|
|
againstRef := flag.String("against", "HEAD~1", "git ref to diff drift against (e.g. origin/main, HEAD~1)")
|
|
flag.Parse()
|
|
|
|
api, err := loadIR(*irPath)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "audit:", err)
|
|
os.Exit(exitInvalid)
|
|
}
|
|
overrides, err := spec.LoadOverrides(*ovPath)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "audit:", err)
|
|
os.Exit(exitInvalid)
|
|
}
|
|
|
|
var problems []string
|
|
|
|
problems = append(problems, auditBool(api, overrides)...)
|
|
problems = append(problems, auditAny(api, overrides)...)
|
|
|
|
driftFound := false
|
|
if *checkDrift {
|
|
if d, err := auditDrift(*irPath, *againstRef, api); err != nil {
|
|
fmt.Fprintln(os.Stderr, "audit: drift check skipped:", err)
|
|
} else if len(d) > 0 {
|
|
fmt.Println("Drift detected (signatures changed since HEAD):")
|
|
for _, p := range d {
|
|
fmt.Println(" ", p)
|
|
}
|
|
driftFound = true
|
|
}
|
|
}
|
|
|
|
if len(problems) == 0 && !driftFound {
|
|
fmt.Println("audit: clean")
|
|
os.Exit(exitClean)
|
|
}
|
|
|
|
if len(problems) > 0 {
|
|
fmt.Println("Codegen fallbacks requiring action:")
|
|
for _, p := range problems {
|
|
fmt.Println(" ", p)
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("To resolve: extend cmd/scrape/method.go regex patterns OR")
|
|
fmt.Println("add an entry to internal/spec/overrides.json.")
|
|
os.Exit(exitFallback)
|
|
}
|
|
|
|
// drift only, no fallbacks
|
|
os.Exit(exitDrift)
|
|
}
|
|
|
|
func loadIR(path string) (*spec.API, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open IR: %w", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
var api spec.API
|
|
if err := json.NewDecoder(f).Decode(&api); err != nil {
|
|
return nil, fmt.Errorf("decode IR: %w", err)
|
|
}
|
|
return &api, nil
|
|
}
|
|
|
|
// auditBool returns problems for methods returning bool whose docs don't
|
|
// actually say "Returns True" / etc. and which aren't in the approved list.
|
|
func auditBool(api *spec.API, ov *spec.Overrides) []string {
|
|
var out []string
|
|
for _, m := range api.Methods {
|
|
if m.Returns.Kind != spec.KindPrimitive || m.Returns.Name != "bool" {
|
|
continue
|
|
}
|
|
if ov.IsBoolApproved(m.Name) {
|
|
continue
|
|
}
|
|
if looksGenuinelyBool(m.Doc) {
|
|
continue
|
|
}
|
|
snippet := m.Doc
|
|
if len(snippet) > 120 {
|
|
snippet = snippet[:120] + "…"
|
|
}
|
|
out = append(out, fmt.Sprintf("bool fallback: %s — doc: %q", m.Name, snippet))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func looksGenuinelyBool(doc string) bool {
|
|
for _, p := range []string{
|
|
"Returns True", "Returns true",
|
|
"True is returned", "true is returned",
|
|
"Returns Boolean", "Returns Bool",
|
|
} {
|
|
if strings.Contains(doc, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// auditAny scans the IR for any KindOneOf TypeRef that would render as
|
|
// `any` in generated code (not matched by ChatID/InputFile-or-string/known
|
|
// union heuristics). Reports each occurrence with location, except the
|
|
// locations approved in overrides.
|
|
func auditAny(api *spec.API, ov *spec.Overrides) []string {
|
|
var out []string
|
|
isKnownUnion := func(variants []string) bool {
|
|
if hasVariants(variants, "int64", "string") {
|
|
return true // ChatID
|
|
}
|
|
if hasVariants(variants, "InputFile", "string") {
|
|
return true // *InputFile
|
|
}
|
|
// ReplyMarkup union: all four keyboard types — emitter renders as `any` intentionally
|
|
if hasVariants(variants, "InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply") {
|
|
return true
|
|
}
|
|
for _, t := range api.Types {
|
|
if len(t.OneOf) > 0 && sameSet(variants, t.OneOf) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
isAny := func(tr spec.TypeRef) bool {
|
|
return tr.Kind == spec.KindOneOf && !isKnownUnion(tr.Variants)
|
|
}
|
|
for _, t := range api.Types {
|
|
for _, f := range t.Fields {
|
|
if isAny(f.Type) && !ov.IsAnyApproved(t.Name+"."+f.Name) {
|
|
out = append(out, fmt.Sprintf("any field: %s.%s (variants=%v)", t.Name, f.Name, f.Type.Variants))
|
|
}
|
|
}
|
|
}
|
|
for _, m := range api.Methods {
|
|
if isAny(m.Returns) && !ov.IsAnyApproved(m.Name) {
|
|
out = append(out, fmt.Sprintf("any return: %s (variants=%v)", m.Name, m.Returns.Variants))
|
|
}
|
|
for _, p := range m.Params {
|
|
if isAny(p.Type) && !ov.IsAnyApproved(m.Name+"."+p.Name) {
|
|
out = append(out, fmt.Sprintf("any param: %s.%s (variants=%v)", m.Name, p.Name, p.Type.Variants))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hasVariants(got []string, want ...string) bool {
|
|
if len(got) != len(want) {
|
|
return false
|
|
}
|
|
seen := map[string]int{}
|
|
for _, g := range got {
|
|
seen[g]++
|
|
}
|
|
for _, w := range want {
|
|
seen[w]--
|
|
}
|
|
for _, v := range seen {
|
|
if v != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sameSet(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
return hasVariants(a, b...)
|
|
}
|
|
|
|
// auditDrift compares method/return signatures between the given git ref's
|
|
// version of irPath and the in-memory current IR. Returns a list of
|
|
// human-readable change descriptions.
|
|
func auditDrift(irPath, againstRef string, current *spec.API) ([]string, error) {
|
|
cmd := exec.Command("git", "show", againstRef+":"+irPath) // #nosec G204 - operator tool, ref controlled by caller
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("git show %s: %w", againstRef, err)
|
|
}
|
|
var prev spec.API
|
|
if err := json.Unmarshal(out, &prev); err != nil {
|
|
return nil, fmt.Errorf("decode %s IR: %w", againstRef, err)
|
|
}
|
|
return diffSignatures(&prev, current), nil
|
|
}
|
|
|
|
func diffSignatures(prev, cur *spec.API) []string {
|
|
var changes []string
|
|
|
|
pmeth := indexByName(prev.Methods, func(m spec.MethodDecl) string { return m.Name })
|
|
cmeth := indexByName(cur.Methods, func(m spec.MethodDecl) string { return m.Name })
|
|
|
|
for name, p := range pmeth {
|
|
c, ok := cmeth[name]
|
|
if !ok {
|
|
changes = append(changes, fmt.Sprintf("removed method: %s", name))
|
|
continue
|
|
}
|
|
if !typeRefEqual(p.Returns, c.Returns) {
|
|
changes = append(changes, fmt.Sprintf(
|
|
"method %s return changed: %s → %s",
|
|
name, formatTypeRef(p.Returns), formatTypeRef(c.Returns)))
|
|
}
|
|
}
|
|
for name := range cmeth {
|
|
if _, ok := pmeth[name]; !ok {
|
|
changes = append(changes, fmt.Sprintf("added method: %s", name))
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func indexByName[T any](xs []T, f func(T) string) map[string]T {
|
|
out := map[string]T{}
|
|
for _, x := range xs {
|
|
out[f(x)] = x
|
|
}
|
|
return out
|
|
}
|
|
|
|
func typeRefEqual(a, b spec.TypeRef) bool {
|
|
if a.Kind != b.Kind || a.Name != b.Name {
|
|
return false
|
|
}
|
|
if (a.ElemType == nil) != (b.ElemType == nil) {
|
|
return false
|
|
}
|
|
if a.ElemType != nil && !typeRefEqual(*a.ElemType, *b.ElemType) {
|
|
return false
|
|
}
|
|
return sameSet(a.Variants, b.Variants)
|
|
}
|
|
|
|
func formatTypeRef(t spec.TypeRef) string {
|
|
switch t.Kind {
|
|
case spec.KindPrimitive:
|
|
return t.Name
|
|
case spec.KindNamed:
|
|
return t.Name
|
|
case spec.KindArray:
|
|
if t.ElemType != nil {
|
|
return "[]" + formatTypeRef(*t.ElemType)
|
|
}
|
|
return "[]any"
|
|
case spec.KindOneOf:
|
|
return "(" + strings.Join(t.Variants, " | ") + ")"
|
|
}
|
|
return "?"
|
|
}
|