1// Command gen turns spec/registry.yaml into the three artifacts that speak2// the semantic conventions (issue #5 §8, §10 decision 9).3//4// format/semconv/registry_gen.go the Go registry5// analysis/semconv/keys.ts the TypeScript lanes' key constants6// docs/semconv/REGISTRY.md the document a reader learns it from7//8// Usage:9//10// go run ./spec/gen # write the artifacts11// go run ./spec/gen -check # report drift, write nothing12//13// The generator owns those three files completely and nothing else. The value14// checkers stay hand-written in format/semconv/registry.go and are referenced15// by name from the spec; a name this program does not know fails codegen,16// which is what keeps "checked by the registry" from quietly meaning "checked17// by nothing".18//19// Output is a pure function of the spec: every listing is emitted in the20// spec's own order or in sorted order, no map is ranged over, and the Go21// artifact goes through go/format, so two runs on one spec produce identical22// bytes. spec/gen's test asserts exactly that, and asserts the committed23// artifacts equal what a run produces.24package main2526import (27 "bytes"28 "flag"29 "fmt"30 "go/format"31 "os"32 "path/filepath"33 "sort"34 "strings"3536 "gopkg.in/yaml.v3"37)3839// The artifacts, by module-relative path. The generator writes these three and40// refuses to be pointed anywhere else.41const (42 specFile = "spec/registry.yaml"43 goFile = "format/semconv/registry_gen.go"44 tsFile = "analysis/semconv/keys.ts"45 mdFile = "docs/semconv/REGISTRY.md"46)4748// banner is the first line of every generated artifact, in that artifact's own49// comment syntax. The Go form is the one `go` tooling recognises.50const banner = "Code generated by spec/gen from spec/registry.yaml. DO NOT EDIT."5152// checkers are the hand-written value checkers of format/semconv/registry.go,53// with how the generated registry calls each one. A spec entry naming anything54// else is a codegen failure rather than a key that checks nothing.55var checkers = map[string]func(k key) string{56 "enum": func(k key) string {57 names := make([]string, 0, len(k.Values))58 for _, v := range k.Values {59 names = append(names, v.Const)60 }61 return "enum(" + strings.Join(names, ", ") + ")"62 },63 "numbers": func(k key) string { return fmt.Sprintf("numbers(%d)", k.Arity) },64 "slug": func(key) string { return "slug" },65 "decimal": func(key) string { return "decimal" },66 "positiveDecimal": func(key) string { return "positiveDecimal" },67 "huc12": func(key) string { return "huc12" },68 "setName": func(key) string { return "setName" },69}7071type spec struct {72 Version int `yaml:"version"`73 VersionDoc string `yaml:"version_doc"`74 Namespace string `yaml:"namespace"`75 NamespaceDoc string `yaml:"namespace_doc"`7677 Document struct {78 Title string `yaml:"title"`79 Intro string `yaml:"intro"`80 Rules string `yaml:"rules"`81 } `yaml:"document"`8283 EntitiesDoc string `yaml:"entities_doc"`84 Entities []entity `yaml:"entities"`85 StabilitiesDoc string `yaml:"stabilities_doc"`86 Stabilities []named `yaml:"stabilities"`87 VocabulariesDoc string `yaml:"vocabularies_doc"`88 KeysDoc string `yaml:"keys_doc"`89 Keys []key `yaml:"keys"`90 PolicyKeysDoc string `yaml:"policy_keys_doc"`91 PolicyKeys []key `yaml:"policy_keys"`92}9394type named struct {95 Name string `yaml:"name"`96 Const string `yaml:"const"`97}9899// vocab is one admitted value of an enum key: the string on the wire and the100// Go constant that spells it.101type vocab struct {102 Const string `yaml:"const"`103 Value string `yaml:"value"`104}105106type entity struct {107 Name string `yaml:"name"`108 Const string `yaml:"const"`109 What string `yaml:"what"`110 Where string `yaml:"where"`111}112113type key struct {114 Key string `yaml:"key"`115 Const string `yaml:"const"`116 Entity string `yaml:"entity"`117 Stability string `yaml:"stability"`118 Check string `yaml:"check"`119 Values []vocab `yaml:"values"`120 Arity int `yaml:"arity"`121 Doc string `yaml:"doc"`122 ValuesMD string `yaml:"values_md"`123 Meaning string `yaml:"meaning"`124}125126func main() {127 check := flag.Bool("check", false, "report artifacts that are out of date; write nothing")128 root := flag.String("root", "", "repository root (default: the nearest directory above with a go.mod)")129 flag.Parse()130131 dir, err := repoRoot(*root)132 if err != nil {133 fail(err)134 }135 artifacts, err := build(dir)136 if err != nil {137 fail(err)138 }139140 stale := 0141 for _, path := range sorted(artifacts) {142 want := artifacts[path]143 full := filepath.Join(dir, filepath.FromSlash(path))144 have, readErr := os.ReadFile(full)145 if readErr == nil && bytes.Equal(have, want) {146 continue147 }148 stale++149 if *check {150 fmt.Fprintf(os.Stderr, "gen: %s is out of date; run `go generate ./format/semconv`\n", path)151 continue152 }153 if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {154 fail(err)155 }156 if err := os.WriteFile(full, want, 0o644); err != nil {157 fail(err)158 }159 fmt.Fprintf(os.Stderr, "gen: wrote %s\n", path)160 }161162 switch {163 case *check && stale > 0:164 os.Exit(1)165 case stale == 0:166 fmt.Fprintf(os.Stderr, "gen: %d artifacts already agree with %s\n", len(artifacts), specFile)167 }168}169170func fail(err error) {171 fmt.Fprintln(os.Stderr, "gen:", err)172 os.Exit(1)173}174175// build reads the spec under dir and renders every artifact. It is the whole176// program, factored out so the test can call it without a filesystem of its177// own beyond the repository it already sits in.178func build(dir string) (map[string][]byte, error) {179 raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(specFile)))180 if err != nil {181 return nil, fmt.Errorf("read the spec: %w", err)182 }183 var s spec184 decoder := yaml.NewDecoder(bytes.NewReader(raw))185 decoder.KnownFields(true)186 if err := decoder.Decode(&s); err != nil {187 return nil, fmt.Errorf("parse %s: %w", specFile, err)188 }189 if err := s.validate(); err != nil {190 return nil, fmt.Errorf("%s: %w", specFile, err)191 }192 goSource, err := s.goRegistry()193 if err != nil {194 return nil, err195 }196 return map[string][]byte{197 goFile: goSource,198 tsFile: s.tsKeys(),199 mdFile: s.document(),200 }, nil201}202203// validate holds the spec to what the artifacts need before anything is204// rendered, so a malformed entry is one message rather than a compile error in205// generated code somebody then has to read.206func (s *spec) validate() error {207 if s.Version <= 0 {208 return fmt.Errorf("version must be positive, not %d", s.Version)209 }210 if s.Namespace == "" {211 return fmt.Errorf("namespace is empty")212 }213 entities := map[string]bool{}214 for _, e := range s.Entities {215 entities[e.Name] = true216 }217 stabilities := map[string]bool{}218 for _, t := range s.Stabilities {219 stabilities[t.Name] = true220 }221222 seenKey, seenConst := map[string]bool{}, map[string]bool{}223 all := append(append([]key{}, s.Keys...), s.PolicyKeys...)224 for _, k := range all {225 if !strings.HasPrefix(k.Key, s.Namespace) {226 return fmt.Errorf("%s is outside the %q namespace", k.Key, s.Namespace)227 }228 if seenKey[k.Key] {229 return fmt.Errorf("%s is declared twice", k.Key)230 }231 if seenConst[k.Const] {232 return fmt.Errorf("the constant %s is declared twice", k.Const)233 }234 seenKey[k.Key], seenConst[k.Const] = true, true235 }236 for _, k := range s.Keys {237 if !entities[k.Entity] {238 return fmt.Errorf("%s attaches to %q, which is not a declared entity", k.Key, k.Entity)239 }240 if !stabilities[k.Stability] {241 return fmt.Errorf("%s carries stability %q, which is not a declared tier", k.Key, k.Stability)242 }243 if _, known := checkers[k.Check]; !known {244 return fmt.Errorf("%s is checked by %q, which is not a checker "+245 "format/semconv/registry.go writes", k.Key, k.Check)246 }247 if (k.Check == "enum") != (len(k.Values) > 0) {248 return fmt.Errorf("%s: an enum needs a vocabulary and nothing else may carry one", k.Key)249 }250 if (k.Check == "numbers") != (k.Arity > 0) {251 return fmt.Errorf("%s: an arity belongs to `numbers` and to nothing else", k.Key)252 }253 if k.ValuesMD == "" || k.Meaning == "" {254 return fmt.Errorf("%s has no documented values or meaning", k.Key)255 }256 }257 if len(s.Keys) > 0 && s.Keys[0].Doc == "" {258 return fmt.Errorf("the first key must carry a doc comment for the rest to join")259 }260 return nil261}262263// entityConst and stabilityConst answer the Go constant a spec name maps to.264// validate has already established that both exist.265func (s *spec) entityConst(name string) string {266 for _, e := range s.Entities {267 if e.Name == name {268 return e.Const269 }270 }271 return ""272}273274func (s *spec) stabilityConst(name string) string {275 for _, t := range s.Stabilities {276 if t.Name == name {277 return t.Const278 }279 }280 return ""281}282283func (s *spec) goRegistry() ([]byte, error) {284 var b strings.Builder285 fmt.Fprintf(&b, "// %s\n\npackage semconv\n\n", banner)286287 b.WriteString(comment(s.NamespaceDoc, ""))288 fmt.Fprintf(&b, "const Namespace = %q\n\n", s.Namespace)289290 b.WriteString(comment(s.VersionDoc, ""))291 fmt.Fprintf(&b, "const Version = %d\n\n", s.Version)292293 b.WriteString(comment(s.EntitiesDoc, ""))294 b.WriteString("const (\n")295 for _, e := range s.Entities {296 fmt.Fprintf(&b, "\t%s Entity = %q\n", e.Const, e.Name)297 }298 b.WriteString(")\n\n")299300 b.WriteString(comment(s.StabilitiesDoc, ""))301 b.WriteString("const (\n")302 for _, t := range s.Stabilities {303 fmt.Fprintf(&b, "\t%s Stability = %q\n", t.Const, t.Name)304 }305 b.WriteString(")\n\n")306307 b.WriteString(comment(s.KeysDoc, ""))308 b.WriteString("const (\n")309 for index, k := range s.Keys {310 // A key with no doc joins the comment above it, which is how two keys311 // that only make sense together are read together.312 if k.Doc != "" {313 if index > 0 {314 b.WriteString("\n")315 }316 b.WriteString(comment(k.Doc, "\t"))317 }318 fmt.Fprintf(&b, "\t%s = %q\n", k.Const, k.Key)319 }320 b.WriteString(")\n\n")321322 for _, k := range s.PolicyKeys {323 b.WriteString(comment(k.Doc, ""))324 fmt.Fprintf(&b, "const %s = %q\n\n", k.Const, k.Key)325 }326327 b.WriteString(comment(s.VocabulariesDoc, ""))328 b.WriteString("const (\n")329 groups := 0330 for _, k := range s.Keys {331 if len(k.Values) == 0 {332 continue333 }334 if groups > 0 {335 b.WriteString("\n")336 }337 groups++338 for _, v := range k.Values {339 fmt.Fprintf(&b, "\t%s = %q\n", v.Const, v.Value)340 }341 }342 b.WriteString(")\n\n")343344 b.WriteString(comment("registry is the whole vocabulary, and it is generated: "+345 "spec/registry.yaml is the one source docs/semconv/REGISTRY.md and the "+346 "TypeScript lanes' key constants are cut from too.", ""))347 b.WriteString("var registry = map[string]definition{\n")348 for _, k := range s.Keys {349 fmt.Fprintf(&b, "\t%s: {%s, %s, %s},\n",350 k.Const, s.entityConst(k.Entity), s.stabilityConst(k.Stability), checkers[k.Check](k))351 }352 b.WriteString("}\n")353354 source, err := format.Source([]byte(b.String()))355 if err != nil {356 return nil, fmt.Errorf("the generated Go does not parse: %w", err)357 }358 return source, nil359}360361func (s *spec) tsKeys() []byte {362 var b strings.Builder363 fmt.Fprintf(&b, "// %s\n//\n", banner)364 b.WriteString(comment("The published attribute vocabulary, as the TypeScript lanes speak it "+365 "(issue #5 §3.2: analysis/ depends on its own math and the published attribute "+366 "vocabulary; render/ depends on analysis/ and published contracts). These are the "+367 "keys and nothing else -- no checker, no validation, no reader. A lane that wants "+368 "to know what a key means reads docs/semconv/REGISTRY.md, which is cut from the "+369 "same spec.", ""))370371 b.WriteString("\n/** The namespace every governed key carries. */\n")372 fmt.Fprintf(&b, "export const NAMESPACE = %q;\n", s.Namespace)373 b.WriteString("\n/** The conventions version a bundle declares in its manifest. */\n")374 fmt.Fprintf(&b, "export const CONVENTIONS_VERSION = %d;\n", s.Version)375376 for _, k := range s.Keys {377 b.WriteString("\n")378 b.WriteString(jsdoc(fmt.Sprintf("`%s`, %s. %s", k.Entity, k.Stability, k.Meaning)))379 fmt.Fprintf(&b, "export const %s = %q;\n", tsConst(k.Key, s.Namespace), k.Key)380 }381 return []byte(b.String())382}383384func (s *spec) document() []byte {385 var b strings.Builder386 fmt.Fprintf(&b, "<!-- %s -->\n\n", banner)387 fmt.Fprintf(&b, "# %s\n\n", s.fill(s.Document.Title))388 b.WriteString(strings.TrimRight(s.fill(s.Document.Intro), "\n"))389390 b.WriteString("\n\n## Entities\n\n")391 b.WriteString("| Entity | What it is | Where its attributes ride |\n|---|---|---|\n")392 for _, e := range s.Entities {393 fmt.Fprintf(&b, "| `%s` | %s | %s |\n", e.Name, e.What, e.Where)394 }395396 b.WriteString("\n## Attributes\n\n")397 b.WriteString("| Key | Entity | Values | Stability | Meaning |\n|---|---|---|---|---|\n")398 for _, k := range s.Keys {399 fmt.Fprintf(&b, "| `%s` | %s | %s | %s | %s |\n",400 k.Key, k.Entity, k.ValuesMD, k.Stability, k.Meaning)401 }402403 if len(s.PolicyKeys) > 0 {404 fmt.Fprintf(&b, "\n## %s\n\n", s.PolicyKeysDoc)405 b.WriteString("| Name | Meaning |\n|---|---|\n")406 for _, k := range s.PolicyKeys {407 fmt.Fprintf(&b, "| `%s` | %s |\n", k.Key, k.Meaning)408 }409 }410411 b.WriteString("\n## Rules\n\n")412 b.WriteString(strings.TrimRight(s.fill(s.Document.Rules), "\n") + "\n")413 return []byte(b.String())414}415416// fill substitutes the one placeholder the document prose may carry, so the417// version number a reader sees is the version the code carries.418func (s *spec) fill(text string) string {419 return strings.ReplaceAll(text, "{{version}}", fmt.Sprint(s.Version))420}421422// tsConst derives a TypeScript constant name from a key, so the two can never423// drift apart: atlas.geometry.equirect.px becomes KEY_GEOMETRY_EQUIRECT_PX.424func tsConst(key, namespace string) string {425 tail := strings.TrimPrefix(key, namespace)426 return "KEY_" + strings.ToUpper(strings.NewReplacer(".", "_", "-", "_").Replace(tail))427}428429// comment renders prose as a Go comment block at an indent, wrapping greedily.430// A blank line in the spec is a paragraph break and survives as one.431func comment(text, indent string) string {432 var b strings.Builder433 for index, paragraph := range strings.Split(strings.TrimSpace(text), "\n") {434 if index > 0 {435 b.WriteString(indent + "//\n")436 }437 for _, line := range wrap(paragraph, 70) {438 b.WriteString(indent + "// " + line + "\n")439 }440 }441 return b.String()442}443444// jsdoc renders one paragraph as a JSDoc block.445func jsdoc(text string) string {446 lines := wrap(strings.TrimSpace(text), 74)447 if len(lines) == 1 {448 return "/** " + lines[0] + " */\n"449 }450 var b strings.Builder451 b.WriteString("/**\n")452 for _, line := range lines {453 b.WriteString(" * " + line + "\n")454 }455 b.WriteString(" */\n")456 return b.String()457}458459// wrap breaks a paragraph into lines of at most width runes, greedily. A word460// longer than the width gets a line of its own rather than being cut.461func wrap(paragraph string, width int) []string {462 words := strings.Fields(paragraph)463 if len(words) == 0 {464 return []string{""}465 }466 lines := []string{}467 line := words[0]468 for _, word := range words[1:] {469 if len([]rune(line))+1+len([]rune(word)) > width {470 lines = append(lines, line)471 line = word472 continue473 }474 line += " " + word475 }476 return append(lines, line)477}478479func sorted(artifacts map[string][]byte) []string {480 out := make([]string, 0, len(artifacts))481 for path := range artifacts {482 out = append(out, path)483 }484 sort.Strings(out)485 return out486}487488// repoRoot resolves the directory the module-relative artifact paths hang off.489func repoRoot(declared string) (string, error) {490 if declared != "" {491 return declared, nil492 }493 dir, err := os.Getwd()494 if err != nil {495 return "", fmt.Errorf("locate the repository root: %w", err)496 }497 for {498 if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {499 return dir, nil500 }501 parent := filepath.Dir(dir)502 if parent == dir {503 return "", fmt.Errorf("no go.mod above the working directory")504 }505 dir = parent506 }507}