Files
fn_registry/functions/infra/audit_app_drift.go
T
egutierrez b9716a7cd6 chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)
Snapshot de WIP acumulado de sesiones previas antes de merge wave 1
del flow 0008 (kanban_cpp + agent_runner_api + DoD schema).

Incluye:
- dev/flows/0008-kanban-cpp-and-agent-workflows.md
- dev/issues/0112-0119*.md (7 sub-issues)
- WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:17:08 +02:00

337 lines
9.8 KiB
Go

package infra
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// AppDriftEntry describes an app and its module drift state relative to the registry.
type AppDriftEntry struct {
AppName string `json:"app_name"`
AppID string `json:"app_id"`
DirPath string `json:"dir_path"`
BuildKind string `json:"build_kind"` // "windows-deployed" | "windows-build" | "linux-build" | ""
BinaryPath string `json:"binary_path"` // path inspected (or modules_generated.cpp)
LinkedModules map[string]string `json:"linked_modules"` // module name -> embedded version
RegistryModules map[string]string `json:"registry_modules"` // module name -> registry version
Stale []string `json:"stale"` // modules with drift (linked != registry)
Status string `json:"status"` // "ok" | "drift" | "no-build"
}
// AuditAppDrift scans all C++ apps in the registry, reads the module versions
// embedded in their compiled artifacts, and compares them against the versions
// declared in modules/*/module.md.
//
// Binary inspection priority (first found wins):
// 1. windows-deployed: <windowsDeployRoot>/<name>/<name>.exe — PE binary search
// 2. windows-build: cpp/build/windows/apps/<name>/<name>_modules_generated.cpp
// 3. linux-build: cpp/build/linux/apps/<name>/<name>_modules_generated.cpp
//
// If windowsDeployRoot is empty, only cpp/build paths are checked.
// registryRoot is the absolute path to the fn_registry root.
func AuditAppDrift(registryRoot string, windowsDeployRoot string) ([]AppDriftEntry, error) {
// 1. Read registry module versions from modules/*/module.md
registryMods, err := readRegistryModules(registryRoot)
if err != nil {
return nil, fmt.Errorf("audit_app_drift: read registry modules: %w", err)
}
// 2. Find all C++ app.md files.
appDirs, err := findAppDirs(registryRoot)
if err != nil {
return nil, fmt.Errorf("audit_app_drift: find app dirs: %w", err)
}
var result []AppDriftEntry
for _, dir := range appDirs {
appMDPath := filepath.Join(dir, "app.md")
meta, err := readCppAppMeta(appMDPath)
if err != nil || meta.Lang != "cpp" {
continue
}
entry := AppDriftEntry{
AppName: meta.Name,
AppID: meta.ID,
DirPath: meta.DirPath,
LinkedModules: map[string]string{},
RegistryModules: registryMods,
}
// 3. Find artifact and extract linked module versions.
kind, artifactPath, linked := resolveLinkedVersions(
meta.Name, registryRoot, windowsDeployRoot,
)
entry.BuildKind = kind
entry.BinaryPath = artifactPath
entry.LinkedModules = linked
if kind == "" || linked == nil {
entry.Status = "no-build"
result = append(result, entry)
continue
}
// 4. Compute drift: only for modules that are both linked AND in registry.
var stale []string
for modName, linkedVer := range linked {
regVer, inRegistry := registryMods[modName]
if !inRegistry {
continue
}
if linkedVer != regVer {
stale = append(stale, modName)
}
}
sort.Strings(stale)
entry.Stale = stale
if len(stale) > 0 {
entry.Status = "drift"
} else {
entry.Status = "ok"
}
result = append(result, entry)
}
sort.Slice(result, func(i, j int) bool { return result[i].AppID < result[j].AppID })
return result, nil
}
// readRegistryModules reads modules/*/module.md and returns name -> version map.
// Uses stdlib only: reads files line by line, extracts name/version with regex.
func readRegistryModules(root string) (map[string]string, error) {
modulesDir := filepath.Join(root, "modules")
entries, err := os.ReadDir(modulesDir)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
out := map[string]string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
mdPath := filepath.Join(modulesDir, e.Name(), "module.md")
name, version, err := parseModuleMD(mdPath)
if err != nil || name == "" || version == "" {
continue
}
out[name] = version
}
return out, nil
}
var (
moduleNameRE = regexp.MustCompile(`(?m)^name:\s*(.+?)\s*$`)
moduleVersionRE = regexp.MustCompile(`(?m)^version:\s*(.+?)\s*$`)
)
// parseModuleMD extracts name and version from a module.md frontmatter using
// simple line-by-line regex (no external YAML deps).
func parseModuleMD(path string) (name, version string, err error) {
data, err := os.ReadFile(path)
if err != nil {
return "", "", err
}
// Restrict to frontmatter only (between --- markers).
fm := extractFrontmatter(string(data))
if m := moduleNameRE.FindStringSubmatch(fm); len(m) > 1 {
name = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := moduleVersionRE.FindStringSubmatch(fm); len(m) > 1 {
version = strings.TrimSpace(strings.Trim(m[1], `"`))
}
return name, version, nil
}
// extractFrontmatter returns the YAML block between the first --- pair, or the
// full content if no markers found.
func extractFrontmatter(content string) string {
if !strings.HasPrefix(content, "---") {
return content
}
rest := content[3:]
// Skip optional newline after opening ---
if strings.HasPrefix(rest, "\n") {
rest = rest[1:]
}
end := strings.Index(rest, "\n---")
if end < 0 {
return rest
}
return rest[:end]
}
// cppAppMeta holds the fields we need from an app.md for C++ apps.
type cppAppMeta struct {
Name string
ID string
Lang string
Domain string
DirPath string
}
var (
appLangRE = regexp.MustCompile(`(?m)^lang:\s*(.+?)\s*$`)
appNameRE = regexp.MustCompile(`(?m)^name:\s*(.+?)\s*$`)
appDomainRE = regexp.MustCompile(`(?m)^domain:\s*(.+?)\s*$`)
appDirPathRE = regexp.MustCompile(`(?m)^dir_path:\s*"?(.+?)"?\s*$`)
)
// readCppAppMeta extracts name, lang, domain and dir_path from an app.md.
func readCppAppMeta(path string) (cppAppMeta, error) {
data, err := os.ReadFile(path)
if err != nil {
return cppAppMeta{}, err
}
fm := extractFrontmatter(string(data))
var meta cppAppMeta
if m := appNameRE.FindStringSubmatch(fm); len(m) > 1 {
meta.Name = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := appLangRE.FindStringSubmatch(fm); len(m) > 1 {
meta.Lang = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := appDomainRE.FindStringSubmatch(fm); len(m) > 1 {
meta.Domain = strings.TrimSpace(strings.Trim(m[1], `"`))
}
if m := appDirPathRE.FindStringSubmatch(fm); len(m) > 1 {
meta.DirPath = strings.TrimSpace(strings.Trim(m[1], `"`))
}
// Derive ID: name_lang_domain
meta.ID = meta.Name
if meta.Lang != "" {
meta.ID += "_" + meta.Lang
}
if meta.Domain != "" {
meta.ID += "_" + meta.Domain
}
return meta, nil
}
// resolveLinkedVersions finds the best artifact for a given app and extracts
// module name->version from it.
//
// Priority: windows-deployed > windows-build > linux-build.
// Returns ("", "", nil) when no artifact is found.
func resolveLinkedVersions(
appName string,
registryRoot string,
windowsDeployRoot string,
) (kind string, artifactPath string, linked map[string]string) {
// 1. Windows deployed exe.
if windowsDeployRoot != "" {
exePath := filepath.Join(windowsDeployRoot, appName, appName+".exe")
if _, err := os.Stat(exePath); err == nil {
if mods := extractModulesFromBinary(exePath); len(mods) > 0 {
return "windows-deployed", exePath, mods
}
}
}
// 2. Windows build modules_generated.cpp.
winGenCPP := filepath.Join(
registryRoot, "cpp", "build", "windows", "apps", appName,
appName+"_modules_generated.cpp",
)
if _, err := os.Stat(winGenCPP); err == nil {
if mods := extractModulesFromGeneratedCPP(winGenCPP); len(mods) > 0 {
return "windows-build", winGenCPP, mods
}
}
// 3. Linux build modules_generated.cpp.
linuxGenCPP := filepath.Join(
registryRoot, "cpp", "build", "linux", "apps", appName,
appName+"_modules_generated.cpp",
)
if _, err := os.Stat(linuxGenCPP); err == nil {
if mods := extractModulesFromGeneratedCPP(linuxGenCPP); len(mods) > 0 {
return "linux-build", linuxGenCPP, mods
}
}
return "", "", nil
}
// binaryModuleRE matches the pattern <module_name>\x00<semver>\x00 as it
// appears in PE (Windows) binaries compiled from the generated ModuleInfo struct.
// Group 1 = module name, group 2 = version string.
var binaryModuleRE = regexp.MustCompile(`([a-z][a-z0-9_]{1,63})\x00(\d+\.\d+\.\d+)\x00`)
// extractModulesFromBinary scans a PE binary for the contiguous pattern
// <module_name>\x00<semver>\x00 that the MSVC/MinGW compiler emits for the
// ModuleInfo struct string literals. Returns nil when no matches are found.
//
// Note: ELF (Linux) binaries may not have this contiguous layout — use
// extractModulesFromGeneratedCPP instead for Linux builds.
func extractModulesFromBinary(path string) map[string]string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string]string{}
for _, m := range binaryModuleRE.FindAllSubmatch(data, -1) {
if len(m) < 3 {
continue
}
name := string(m[1])
ver := string(m[2])
if _, seen := out[name]; !seen {
out[name] = ver
}
}
if len(out) == 0 {
return nil
}
return out
}
// generatedModuleRE matches lines like: { "data_table", "1.5.0", "..." }
var generatedModuleRE = regexp.MustCompile(`\{\s*"([a-z][a-z0-9_]*)"\s*,\s*"(\d+\.\d+\.\d+)"`)
// extractModulesFromGeneratedCPP parses a *_modules_generated.cpp file and
// returns module name -> version. This is the authoritative source for Linux
// builds and a reliable fallback for Windows builds.
func extractModulesFromGeneratedCPP(path string) map[string]string {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
out := map[string]string{}
for _, m := range generatedModuleRE.FindAllSubmatch(data, -1) {
if len(m) < 3 {
continue
}
name := string(m[1])
ver := string(m[2])
if _, seen := out[name]; !seen {
out[name] = ver
}
}
if len(out) == 0 {
return nil
}
return out
}