package infra import ( "database/sql" "encoding/json" "fmt" "strings" "time" _ "github.com/mattn/go-sqlite3" ) // UnusedFunction represents a registry function with no known consumers. type UnusedFunction struct { ID string Name string Lang string Domain string Tags string // JSON array string, useful for detecting "deprecated" tags AgeDays int // days since updated_at } // FindUnusedFunctions opens /registry.db and returns all // functions that are not referenced by any other function, app, or analysis // via their uses_functions field. // // Pipelines with the "launcher" tag are included if nobody calls them — // they are endpoint-only but still candidates if unlaunched and uncalled. // Plain pipelines (kind = "pipeline", no "launcher" tag) are also included. // Functions with kind = "pipeline" that have the "launcher" tag are excluded // because they are intentionally terminal consumers. func FindUnusedFunctions(registryRoot string) ([]UnusedFunction, error) { dbPath := registryRoot + "/registry.db" db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL") if err != nil { return nil, fmt.Errorf("find_unused_functions: open db: %w", err) } defer db.Close() // Build the set of used IDs from uses_functions across functions, apps, and analyses. usedIDs := make(map[string]struct{}) type usesRow struct { usesJSON string } collectUsed := func(query string) error { rows, err := db.Query(query) if err != nil { return err } defer rows.Close() for rows.Next() { var raw string if err := rows.Scan(&raw); err != nil { return err } var ids []string if err := json.Unmarshal([]byte(raw), &ids); err != nil { continue // malformed JSON, skip } for _, id := range ids { id = strings.TrimSpace(id) if id != "" { usedIDs[id] = struct{}{} } } } return rows.Err() } queries := []string{ "SELECT uses_functions FROM functions WHERE uses_functions != '[]'", "SELECT uses_functions FROM apps WHERE uses_functions != '[]'", "SELECT uses_functions FROM analysis WHERE uses_functions != '[]'", } for _, q := range queries { if err := collectUsed(q); err != nil { return nil, fmt.Errorf("find_unused_functions: collecting used IDs: %w", err) } } // Query all functions; filter out pipelines with "launcher" tag (intentional terminals). rows, err := db.Query(` SELECT id, name, lang, domain, tags, updated_at, kind FROM functions ORDER BY updated_at ASC `) if err != nil { return nil, fmt.Errorf("find_unused_functions: query functions: %w", err) } defer rows.Close() now := time.Now().UTC() var result []UnusedFunction for rows.Next() { var ( id, name, lang, domain, tags, updatedAt, kind string ) if err := rows.Scan(&id, &name, &lang, &domain, &tags, &updatedAt, &kind); err != nil { return nil, fmt.Errorf("find_unused_functions: scan: %w", err) } // Skip if this function is used by someone. if _, used := usedIDs[id]; used { continue } // Pipelines with "launcher" tag are intentional consumers — skip them. if kind == "pipeline" { var tagList []string _ = json.Unmarshal([]byte(tags), &tagList) for _, t := range tagList { if strings.TrimSpace(t) == "launcher" { goto next } } } { updatedTime, err := time.Parse(time.RFC3339, updatedAt) if err != nil { // Try without timezone suffix updatedTime, err = time.Parse("2006-01-02T15:04:05Z", updatedAt) if err != nil { updatedTime = now } } ageDays := int(now.Sub(updatedTime).Hours() / 24) result = append(result, UnusedFunction{ ID: id, Name: name, Lang: lang, Domain: domain, Tags: tags, AgeDays: ageDays, }) } next: } if err := rows.Err(); err != nil { return nil, fmt.Errorf("find_unused_functions: rows: %w", err) } return result, nil }