7eb7b3d0c8
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>
337 lines
9.8 KiB
Go
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
|
|
}
|
|
|