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>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user