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_ 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_ 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 `_` 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: // - /apps/*/ // - /projects/*/apps/*/ func findAppDirs(root string) ([]string, error) { var dirs []string // /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) } } } // /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 _ 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 }