feat(dev): scan full service packages for API stubs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 06:08:17 +00:00
parent fa20cb8aa5
commit f3c5fe9a7b
3 changed files with 122 additions and 23 deletions

View file

@ -40,15 +40,15 @@ func runTestGen() error {
} }
serviceName := dir.Name() serviceName := dir.Name()
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go") internalDir := filepath.Join(pkgDir, serviceName)
publicDir := serviceName publicDir := serviceName
publicTestFile := filepath.Join(publicDir, serviceName+"_test.go") publicTestFile := filepath.Join(publicDir, serviceName+"_test.go")
if !coreio.Local.IsFile(internalFile) { if !coreio.Local.Exists(internalDir) {
continue continue
} }
symbols, err := getExportedSymbols(internalFile) symbols, err := getExportedSymbols(internalDir)
if err != nil { if err != nil {
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName)) return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
} }

View file

@ -32,6 +32,16 @@ const Answer = 42
var Value = Example{} var Value = Example{}
func Run() {} func Run() {}
`))
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "extra.go"), `package demo
type Another struct{}
func Extra() {}
`))
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo_test.go"), `package demo
func Ignored() {}
`)) `))
require.NoError(t, runTestGen()) require.NoError(t, runTestGen())
@ -44,9 +54,12 @@ func Run() {}
require.Contains(t, content, `package demo`) require.Contains(t, content, `package demo`)
require.Contains(t, content, `impl "forge.lthn.ai/core/cli/demo"`) require.Contains(t, content, `impl "forge.lthn.ai/core/cli/demo"`)
require.Contains(t, content, `type _ = impl.Example`) require.Contains(t, content, `type _ = impl.Example`)
require.Contains(t, content, `type _ = impl.Another`)
require.Contains(t, content, `const _ = impl.Answer`) require.Contains(t, content, `const _ = impl.Answer`)
require.Contains(t, content, `var _ = impl.Value`) require.Contains(t, content, `var _ = impl.Value`)
require.Contains(t, content, `var _ = impl.Run`) require.Contains(t, content, `var _ = impl.Run`)
require.Contains(t, content, `var _ = impl.Extra`)
require.NotContains(t, content, `Ignored`)
} }
func TestGeneratePublicAPITestFile_Good(t *testing.T) { func TestGeneratePublicAPITestFile_Good(t *testing.T) {
@ -68,3 +81,35 @@ func TestGeneratePublicAPITestFile_Good(t *testing.T) {
require.True(t, strings.Contains(content, `type _ = impl.Example`)) require.True(t, strings.Contains(content, `type _ = impl.Example`))
require.True(t, strings.Contains(content, `const _ = impl.Answer`)) require.True(t, strings.Contains(content, `const _ = impl.Answer`))
} }
func TestGetExportedSymbols_Good_MultiFile(t *testing.T) {
tmpDir := t.TempDir()
serviceDir := filepath.Join(tmpDir, "demo")
require.NoError(t, io.Local.EnsureDir(serviceDir))
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo.go"), `package demo
type Example struct{}
const Answer = 42
`))
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "extra.go"), `package demo
var Value = Example{}
func Run() {}
`))
require.NoError(t, io.Local.Write(filepath.Join(serviceDir, "demo_test.go"), `package demo
type Ignored struct{}
`))
symbols, err := getExportedSymbols(serviceDir)
require.NoError(t, err)
require.Equal(t, []symbolInfo{
{Name: "Answer", Kind: "const"},
{Name: "Example", Kind: "type"},
{Name: "Run", Kind: "func"},
{Name: "Value", Kind: "var"},
}, symbols)
}

View file

@ -6,12 +6,15 @@ import (
"go/parser" "go/parser"
"go/token" "go/token"
"path/filepath" "path/filepath"
"sort"
"strings"
"text/template" "text/template"
"forge.lthn.ai/core/cli/pkg/cli" // Added "dappco.re/go/core/i18n"
"dappco.re/go/core/i18n" // Added
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
// Added
"forge.lthn.ai/core/cli/pkg/cli"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@ -52,15 +55,15 @@ func runSync() error {
} }
serviceName := dir.Name() serviceName := dir.Name()
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go") internalDir := filepath.Join(pkgDir, serviceName)
publicDir := serviceName publicDir := serviceName
publicFile := filepath.Join(publicDir, serviceName+".go") publicFile := filepath.Join(publicDir, serviceName+".go")
if !coreio.Local.IsFile(internalFile) { if !coreio.Local.Exists(internalDir) {
continue continue
} }
symbols, err := getExportedSymbols(internalFile) symbols, err := getExportedSymbols(internalDir)
if err != nil { if err != nil {
return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName)) return cli.Wrap(err, cli.Sprintf("error getting symbols for service '%s'", serviceName))
} }
@ -74,23 +77,29 @@ func runSync() error {
} }
func getExportedSymbols(path string) ([]symbolInfo, error) { func getExportedSymbols(path string) ([]symbolInfo, error) {
// ParseFile expects a filename/path and reads it using os.Open by default if content is nil. files, err := listGoFiles(path)
// Since we want to use our Medium abstraction, we should read the file content first.
content, err := coreio.Local.Read(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fset := token.NewFileSet() symbolsByName := make(map[string]symbolInfo)
// ParseFile can take content as string (src argument). for _, file := range files {
node, err := parser.ParseFile(fset, path, content, parser.ParseComments) content, err := coreio.Local.Read(file)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, file, content, parser.ParseComments)
if err != nil {
return nil, err
}
for name, obj := range node.Scope.Objects {
if !ast.IsExported(name) {
continue
}
var symbols []symbolInfo
for name, obj := range node.Scope.Objects {
if ast.IsExported(name) {
kind := "unknown" kind := "unknown"
switch obj.Kind { switch obj.Kind {
case ast.Con: case ast.Con:
@ -102,14 +111,59 @@ func getExportedSymbols(path string) ([]symbolInfo, error) {
case ast.Typ: case ast.Typ:
kind = "type" kind = "type"
} }
if kind != "unknown" {
symbols = append(symbols, symbolInfo{Name: name, Kind: kind}) if kind == "unknown" {
continue
}
if _, exists := symbolsByName[name]; !exists {
symbolsByName[name] = symbolInfo{Name: name, Kind: kind}
} }
} }
} }
symbols := make([]symbolInfo, 0, len(symbolsByName))
for _, symbol := range symbolsByName {
symbols = append(symbols, symbol)
}
sort.Slice(symbols, func(i, j int) bool {
if symbols[i].Name == symbols[j].Name {
return symbols[i].Kind < symbols[j].Kind
}
return symbols[i].Name < symbols[j].Name
})
return symbols, nil return symbols, nil
} }
func listGoFiles(path string) ([]string, error) {
entries, err := coreio.Local.List(path)
if err == nil {
files := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
files = append(files, filepath.Join(path, name))
}
sort.Strings(files)
return files, nil
}
if coreio.Local.IsFile(path) {
return []string{path}, nil
}
return nil, err
}
const publicAPITemplate = `// package {{.ServiceName}} provides the public API for the {{.ServiceName}} service. const publicAPITemplate = `// package {{.ServiceName}} provides the public API for the {{.ServiceName}} service.
package {{.ServiceName}} package {{.ServiceName}}