Files
fn_registry/functions/infra/audit_modules_drift.go
T

226 lines
5.7 KiB
Go

package infra
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// ModuleDriftCheck describes per-app drift between app.md uses_modules and
// CMakeLists.txt fn_module_* link calls.
type ModuleDriftCheck struct {
AppID string `json:"app_id"`
AppMD string `json:"app_md"`
CMakeLists string `json:"cmake_lists"`
Declared []string `json:"declared"` // module IDs from uses_modules
Linked []string `json:"linked"` // module names from fn_module_<name>
MissingLinks []string `json:"missing_links"` // declared but not linked
ExtraLinks []string `json:"extra_links"` // linked but not declared
OK bool `json:"ok"`
}
var (
cmakeLinkRE = regexp.MustCompile(`\bfn_module_([a-z0-9_]+)\b`)
)
// AuditModulesDrift scans apps/*/app.md, projects/*/apps/*/app.md, cpp/apps/*/app.md
// and compares uses_modules in the frontmatter against fn_module_<name> link calls
// in the adjacent CMakeLists.txt.
//
// An app is OK when:
// - It has no CMakeLists.txt (non-C++ app) — drift check N/A; skipped silently.
// - declared (modulo `_<lang>` suffix) == linked.
func AuditModulesDrift(root string) ([]ModuleDriftCheck, error) {
candidates, err := findAppDirs(root)
if err != nil {
return nil, err
}
var result []ModuleDriftCheck
for _, dir := range candidates {
appMD := filepath.Join(dir, "app.md")
cmakeLists := filepath.Join(dir, "CMakeLists.txt")
if _, err := os.Stat(cmakeLists); err != nil {
// Non-C++ app or app without CMakeLists. Skip drift check.
continue
}
declared, appID, err := readUsesModules(appMD)
if err != nil {
continue
}
linked, err := readLinkedModules(cmakeLists)
if err != nil {
continue
}
// Normalize declared (module IDs like "data_table_cpp") to module names
// for comparison with link target names ("fn_module_data_table" -> "data_table").
declaredNames := make([]string, 0, len(declared))
for _, d := range declared {
declaredNames = append(declaredNames, stripLangSuffix(d))
}
missing := diffStrings(declaredNames, linked)
extra := diffStrings(linked, declaredNames)
relMD, _ := filepath.Rel(root, appMD)
relCM, _ := filepath.Rel(root, cmakeLists)
result = append(result, ModuleDriftCheck{
AppID: appID,
AppMD: relMD,
CMakeLists: relCM,
Declared: declaredNames,
Linked: linked,
MissingLinks: missing,
ExtraLinks: extra,
OK: len(missing) == 0 && len(extra) == 0,
})
}
sort.Slice(result, func(i, j int) bool { return result[i].AppID < result[j].AppID })
return result, nil
}
// findAppDirs returns directories that contain an app.md file:
// - <root>/apps/*/
// - <root>/projects/*/apps/*/
func findAppDirs(root string) ([]string, error) {
var dirs []string
// <root>/apps/*/
appsRoot := filepath.Join(root, "apps")
if entries, err := os.ReadDir(appsRoot); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
candidate := filepath.Join(appsRoot, e.Name())
if _, err := os.Stat(filepath.Join(candidate, "app.md")); err == nil {
dirs = append(dirs, candidate)
}
}
}
// <root>/projects/*/apps/*/
projectsRoot := filepath.Join(root, "projects")
if projEntries, err := os.ReadDir(projectsRoot); err == nil {
for _, pe := range projEntries {
if !pe.IsDir() {
continue
}
projAppsDir := filepath.Join(projectsRoot, pe.Name(), "apps")
if appEntries, err := os.ReadDir(projAppsDir); err == nil {
for _, ae := range appEntries {
if !ae.IsDir() {
continue
}
candidate := filepath.Join(projAppsDir, ae.Name())
if _, err := os.Stat(filepath.Join(candidate, "app.md")); err == nil {
dirs = append(dirs, candidate)
}
}
}
}
}
return dirs, nil
}
type appFrontmatter struct {
Name string `yaml:"name"`
Lang string `yaml:"lang"`
Domain string `yaml:"domain"`
UsesModules []string `yaml:"uses_modules"`
}
func readUsesModules(path string) (modules []string, appID string, err error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
// Extract YAML frontmatter between leading "---" markers.
if !strings.HasPrefix(string(data), "---") {
return nil, "", fmt.Errorf("missing frontmatter in %s", path)
}
rest := string(data)[4:]
end := strings.Index(rest, "\n---")
if end < 0 {
return nil, "", fmt.Errorf("missing closing --- in %s", path)
}
fm := rest[:end]
var raw appFrontmatter
if err := yaml.Unmarshal([]byte(fm), &raw); err != nil {
return nil, "", err
}
if raw.Name == "" {
return nil, "", fmt.Errorf("no name in %s", path)
}
id := raw.Name
if raw.Lang != "" {
id += "_" + raw.Lang
}
if raw.Domain != "" {
id += "_" + raw.Domain
}
return raw.UsesModules, id, nil
}
func readLinkedModules(cmakePath string) ([]string, error) {
data, err := os.ReadFile(cmakePath)
if err != nil {
return nil, err
}
matches := cmakeLinkRE.FindAllStringSubmatch(string(data), -1)
seen := map[string]bool{}
var out []string
for _, m := range matches {
if len(m) < 2 {
continue
}
if !seen[m[1]] {
seen[m[1]] = true
out = append(out, m[1])
}
}
sort.Strings(out)
return out, nil
}
// stripLangSuffix removes a trailing _<lang> suffix for matching purposes.
// "data_table_cpp" -> "data_table".
func stripLangSuffix(id string) string {
for _, suf := range []string{"_cpp", "_py", "_ts", "_bash", "_go"} {
if strings.HasSuffix(id, suf) {
return id[:len(id)-len(suf)]
}
}
return id
}
// diffStrings returns elements in a that are not in b.
func diffStrings(a, b []string) []string {
bset := map[string]bool{}
for _, x := range b {
bset[x] = true
}
var out []string
for _, x := range a {
if !bset[x] {
out = append(out, x)
}
}
sort.Strings(out)
return out
}