migrate json schema generation (#4270)

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2025-10-10 14:16:28 +00:00
committed by GitHub
parent 18e789c4fd
commit 4ae8f73583
35 changed files with 48 additions and 40 deletions
@@ -0,0 +1,95 @@
package sourcemetadata
import (
"reflect"
"testing"
)
type CompletionTester struct {
saw []any
valid []any
ignore []any
}
func NewCompletionTester(t testing.TB, ignore ...any) *CompletionTester {
t.Helper()
tester := &CompletionTester{
valid: AllTypes(),
ignore: ignore,
}
t.Cleanup(func() {
t.Helper()
tester.validate(t)
})
return tester
}
func (tr *CompletionTester) Ignore(is ...any) {
tr.ignore = append(tr.ignore, keepNonNil(is...)...)
}
func (tr *CompletionTester) Expect(is ...any) {
tr.valid = append(tr.valid, keepNonNil(is...)...)
}
func (tr *CompletionTester) Tested(t testing.TB, m any) {
t.Helper()
if m == nil {
return
}
if len(tr.valid) == 0 {
t.Fatal("no valid metadata types to test against")
}
ty := reflect.TypeOf(m)
for _, v := range tr.ignore {
if reflect.TypeOf(v) == ty {
return
}
}
for _, v := range tr.valid {
if reflect.TypeOf(v) == ty {
tr.saw = append(tr.saw, m)
return
}
}
t.Fatalf("tested metadata type is not valid: %s", ty.Name())
}
func (tr *CompletionTester) validate(t testing.TB) {
t.Helper()
count := make(map[reflect.Type]int)
for _, m := range tr.saw {
count[reflect.TypeOf(m)]++
}
validations:
for _, v := range tr.valid {
ty := reflect.TypeOf(v)
for _, ignore := range tr.ignore {
if ty == reflect.TypeOf(ignore) {
// skip ignored types
continue validations
}
}
if c, exists := count[ty]; c == 0 || !exists {
t.Errorf("metadata type %s is not covered by a test", ty.Name())
}
}
}
func keepNonNil(is ...any) []any {
var result []any
for _, i := range is {
if i != nil {
result = append(result, i)
}
}
return result
}
@@ -0,0 +1,149 @@
package sourcemetadata
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os/exec"
"path/filepath"
"sort"
"strings"
"unicode"
"github.com/scylladb/go-set/strset"
)
var metadataExceptions = strset.New()
func DiscoverTypeNames() ([]string, error) {
root, err := repoRoot()
if err != nil {
return nil, err
}
files, err := filepath.Glob(filepath.Join(root, "syft/source/*.go"))
if err != nil {
return nil, err
}
return findMetadataDefinitionNames(files...)
}
func repoRoot() (string, error) {
root, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", fmt.Errorf("unable to find repo root dir: %+v", err)
}
absRepoRoot, err := filepath.Abs(strings.TrimSpace(string(root)))
if err != nil {
return "", fmt.Errorf("unable to get abs path to repo root: %w", err)
}
return absRepoRoot, nil
}
func findMetadataDefinitionNames(paths ...string) ([]string, error) {
names := strset.New()
usedNames := strset.New()
for _, path := range paths {
metadataDefinitions, usedTypeNames, err := findMetadataDefinitionNamesInFile(path)
if err != nil {
return nil, err
}
// useful for debugging...
// fmt.Println(path)
// fmt.Println("Defs:", metadataDefinitions)
// fmt.Println("Used Types:", usedTypeNames)
// fmt.Println()
names.Add(metadataDefinitions...)
usedNames.Add(usedTypeNames...)
}
// any definition that is used within another struct should not be considered a top-level metadata definition
names.Remove(usedNames.List()...)
strNames := names.List()
sort.Strings(strNames)
// note: 3 is a point-in-time gut check. This number could be updated if new metadata definitions are added, but is not required.
// it is really intended to catch any major issues with the generation process that would generate, say, 0 definitions.
if len(strNames) < 3 {
msg := fmt.Sprintf("not enough metadata definitions found (discovered %d)", len(strNames))
return nil, fmt.Errorf("%v", msg)
}
return strNames, nil
}
func findMetadataDefinitionNamesInFile(path string) ([]string, []string, error) {
// set up the parser
fs := token.NewFileSet()
f, err := parser.ParseFile(fs, path, nil, parser.ParseComments)
if err != nil {
return nil, nil, err
}
var metadataDefinitions []string
var usedTypeNames []string
for _, decl := range f.Decls {
// check if the declaration is a type declaration
spec, ok := decl.(*ast.GenDecl)
if !ok || spec.Tok != token.TYPE {
continue
}
// loop over all types declared in the type declaration
for _, typ := range spec.Specs {
// check if the type is a struct type
spec, ok := typ.(*ast.TypeSpec)
if !ok || spec.Type == nil {
continue
}
structType, ok := spec.Type.(*ast.StructType)
if !ok {
continue
}
// check if the struct type ends with "Metadata"
name := spec.Name.String()
// only look for exported types that end with "Metadata"
if isMetadataTypeCandidate(name) {
// print the full declaration of the struct type
metadataDefinitions = append(metadataDefinitions, name)
usedTypeNames = append(usedTypeNames, typeNamesUsedInStruct(structType)...)
}
}
}
return metadataDefinitions, usedTypeNames, nil
}
func typeNamesUsedInStruct(structType *ast.StructType) []string {
// recursively find all type names used in the struct type
var names []string
for i := range structType.Fields.List {
// capture names of all of the types (not field names)
ast.Inspect(structType.Fields.List[i].Type, func(n ast.Node) bool {
ident, ok := n.(*ast.Ident)
if !ok {
return true
}
// add the type name to the list
names = append(names, ident.Name)
// continue inspecting
return true
})
}
return names
}
func isMetadataTypeCandidate(name string) bool {
return len(name) > 0 &&
strings.HasSuffix(name, "Metadata") &&
unicode.IsUpper(rune(name[0])) && // must be exported
!metadataExceptions.Has(name)
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"fmt"
"os"
"github.com/anchore/syft/internal/sourcemetadata"
"github.com/dave/jennifer/jen"
)
// This program is invoked from syft/internal and generates sourcemetadata/generated.go
const (
srcImport = "github.com/anchore/syft/syft/source"
path = "sourcemetadata/generated.go"
)
func main() {
typeNames, err := sourcemetadata.DiscoverTypeNames()
if err != nil {
panic(fmt.Errorf("unable to get all metadata type names: %w", err))
}
fmt.Printf("updating source metadata type list with %+v types\n", len(typeNames))
f := jen.NewFile("sourcemetadata")
f.HeaderComment("DO NOT EDIT: generated by internal/sourcemetadata/generate/main.go")
f.ImportName(srcImport, "source")
f.Comment("AllTypes returns a list of all source metadata types that syft supports (that are represented in the source.Description.Metadata field).")
f.Func().Id("AllTypes").Params().Index().Any().BlockFunc(func(g *jen.Group) {
g.ReturnFunc(func(g *jen.Group) {
g.Index().Any().ValuesFunc(func(g *jen.Group) {
for _, typeName := range typeNames {
g.Qual(srcImport, typeName).Values()
}
})
})
})
rendered := fmt.Sprintf("%#v", f)
fh, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
panic(fmt.Errorf("unable to open file: %w", err))
}
_, err = fh.WriteString(rendered)
if err != nil {
panic(fmt.Errorf("unable to write file: %w", err))
}
if err := fh.Close(); err != nil {
panic(fmt.Errorf("unable to close file: %w", err))
}
}
+10
View File
@@ -0,0 +1,10 @@
// DO NOT EDIT: generated by internal/sourcemetadata/generate/main.go
package sourcemetadata
import "github.com/anchore/syft/syft/source"
// AllTypes returns a list of all source metadata types that syft supports (that are represented in the source.Description.Metadata field).
func AllTypes() []any {
return []any{source.DirectoryMetadata{}, source.FileMetadata{}, source.ImageMetadata{}, source.SnapMetadata{}}
}
+42
View File
@@ -0,0 +1,42 @@
package sourcemetadata
import (
"reflect"
"strings"
"github.com/anchore/syft/syft/source"
)
var jsonNameFromType = map[reflect.Type][]string{
reflect.TypeOf(source.DirectoryMetadata{}): {"directory", "dir"},
reflect.TypeOf(source.FileMetadata{}): {"file"},
reflect.TypeOf(source.ImageMetadata{}): {"image"},
reflect.TypeOf(source.SnapMetadata{}): {"snap"},
}
func AllTypeNames() []string {
names := make([]string, 0)
for _, t := range AllTypes() {
names = append(names, reflect.TypeOf(t).Name())
}
return names
}
func JSONName(metadata any) string {
if vs, exists := jsonNameFromType[reflect.TypeOf(metadata)]; exists {
return vs[0]
}
return ""
}
func ReflectTypeFromJSONName(name string) reflect.Type {
name = strings.ToLower(name)
for t, vs := range jsonNameFromType {
for _, v := range vs {
if strings.ToLower(v) == name {
return t
}
}
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package sourcemetadata
import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAllNames(t *testing.T) {
// note: this is a form of completion testing relative to the current code base.
expected, err := DiscoverTypeNames()
require.NoError(t, err)
actual := AllTypeNames()
// ensure that the codebase (from ast analysis) reflects the latest code generated state
if !assert.ElementsMatch(t, expected, actual) {
t.Errorf("metadata types not fully represented: \n%s", cmp.Diff(expected, actual))
t.Log("did you add a new source.*Metadata type without updating the JSON schema?")
t.Log("if so, you need to update the schema version and regenerate the JSON schema (make generate-json-schema)")
}
for _, ty := range AllTypes() {
assert.NotEmpty(t, JSONName(ty), "metadata type %q does not have a JSON name", reflect.TypeOf(ty).Name())
}
}