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()
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go")
internalDir := filepath.Join(pkgDir, serviceName)
publicDir := serviceName
publicTestFile := filepath.Join(publicDir, serviceName+"_test.go")
if !coreio.Local.IsFile(internalFile) {
if !coreio.Local.Exists(internalDir) {
continue
}
symbols, err := getExportedSymbols(internalFile)
symbols, err := getExportedSymbols(internalDir)
if err != nil {
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{}
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())
@ -44,9 +54,12 @@ func Run() {}
require.Contains(t, content, `package 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.Another`)
require.Contains(t, content, `const _ = impl.Answer`)
require.Contains(t, content, `var _ = impl.Value`)
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) {
@ -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, `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/token"
"path/filepath"
"sort"
"strings"
"text/template"
"forge.lthn.ai/core/cli/pkg/cli" // Added
"dappco.re/go/core/i18n" // Added
"dappco.re/go/core/i18n"
coreio "dappco.re/go/core/io"
// Added
"forge.lthn.ai/core/cli/pkg/cli"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
@ -52,15 +55,15 @@ func runSync() error {
}
serviceName := dir.Name()
internalFile := filepath.Join(pkgDir, serviceName, serviceName+".go")
internalDir := filepath.Join(pkgDir, serviceName)
publicDir := serviceName
publicFile := filepath.Join(publicDir, serviceName+".go")
if !coreio.Local.IsFile(internalFile) {
if !coreio.Local.Exists(internalDir) {
continue
}
symbols, err := getExportedSymbols(internalFile)
symbols, err := getExportedSymbols(internalDir)
if err != nil {
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) {
// ParseFile expects a filename/path and reads it using os.Open by default if content is nil.
// Since we want to use our Medium abstraction, we should read the file content first.
content, err := coreio.Local.Read(path)
files, err := listGoFiles(path)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
// ParseFile can take content as string (src argument).
node, err := parser.ParseFile(fset, path, content, parser.ParseComments)
if err != nil {
return nil, err
}
symbolsByName := make(map[string]symbolInfo)
for _, file := range files {
content, err := coreio.Local.Read(file)
if err != nil {
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"
switch obj.Kind {
case ast.Con:
@ -102,14 +111,59 @@ func getExportedSymbols(path string) ([]symbolInfo, error) {
case ast.Typ:
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
}
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.
package {{.ServiceName}}