feat(infra): auto-commit con 29 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// CapabilityEntry represents one function/pipeline line in the emitted markdown.
|
||||
type CapabilityEntry struct {
|
||||
ID string
|
||||
Signature string
|
||||
Description string
|
||||
UpdatedAt string // RFC3339 or date only, used for Fresh section
|
||||
}
|
||||
|
||||
// CapabilitiesMdResult holds the three sections for the emitted markdown block.
|
||||
type CapabilitiesMdResult struct {
|
||||
Top20 []CapabilityEntry // top 20 by calls_total (or fallback updated_at)
|
||||
Fresh7d []CapabilityEntry // updated in last 7 days
|
||||
TopPipelines []CapabilityEntry // top 5 pipelines by calls_total (or updated_at)
|
||||
TelemetryAvail bool // false = call_monitor.db not found or empty
|
||||
GeneratedAt string // RFC3339 timestamp
|
||||
}
|
||||
|
||||
const capMdMaxDescLen = 80
|
||||
|
||||
func truncateDesc(s string) string {
|
||||
// strip newlines so description stays on one line
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
if len(s) > capMdMaxDescLen {
|
||||
return s[:capMdMaxDescLen-3] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// EmitCapabilitiesMd generates the markdown block for embedding in CLAUDE.md.
|
||||
// It queries:
|
||||
// 1. registry.db (root) for function metadata (signature, description, updated_at).
|
||||
// 2. call_monitor/operations.db (projects/fn_monitoring/apps/call_monitor/) for
|
||||
// function_stats.calls_total. Falls back gracefully when unavailable.
|
||||
//
|
||||
// Exclusions:
|
||||
// - kind = "component" (frontend components, handled separately)
|
||||
// - lang = "cpp" AND file_path LIKE 'cpp/apps/%' (auto-touched C++ app files)
|
||||
// - tags containing "pendiente-usar" (orphaned/deprecated)
|
||||
func EmitCapabilitiesMd(registryRoot string) (CapabilitiesMdResult, error) {
|
||||
result := CapabilitiesMdResult{
|
||||
GeneratedAt: time.Now().UTC().Format("2006-01-02T15:04"),
|
||||
}
|
||||
|
||||
// --- 1. Open registry.db ---
|
||||
regDB, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=ro&_foreign_keys=on",
|
||||
filepath.Join(registryRoot, "registry.db")))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("emit_capabilities_md: open registry.db: %w", err)
|
||||
}
|
||||
defer regDB.Close()
|
||||
if err := regDB.Ping(); err != nil {
|
||||
return result, fmt.Errorf("emit_capabilities_md: ping registry.db: %w", err)
|
||||
}
|
||||
|
||||
// --- 2. Try opening call_monitor/operations.db ---
|
||||
monitorDBPath := filepath.Join(registryRoot,
|
||||
"projects", "fn_monitoring", "apps", "call_monitor", "operations.db")
|
||||
monitorDB, monErr := sql.Open("sqlite3",
|
||||
fmt.Sprintf("file:%s?mode=ro", monitorDBPath))
|
||||
if monErr == nil {
|
||||
if pingErr := monitorDB.Ping(); pingErr != nil {
|
||||
monitorDB.Close()
|
||||
monitorDB = nil
|
||||
} else {
|
||||
result.TelemetryAvail = true
|
||||
}
|
||||
}
|
||||
if monitorDB != nil {
|
||||
defer monitorDB.Close()
|
||||
}
|
||||
|
||||
// --- 3. Load call counts from function_stats if available ---
|
||||
// map[function_id]calls_total
|
||||
callCounts := map[string]int64{}
|
||||
if result.TelemetryAvail {
|
||||
rows, err := monitorDB.Query(
|
||||
`SELECT function_id, calls_total FROM function_stats WHERE calls_total > 0`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var fid string
|
||||
var ct int64
|
||||
if rows.Scan(&fid, &ct) == nil {
|
||||
callCounts[fid] = ct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Load eligible functions from registry.db ---
|
||||
// Eligible: kind IN ('function','pipeline'), not component, not cpp/apps/, not pendiente-usar
|
||||
type fnRow struct {
|
||||
id string
|
||||
kind string
|
||||
signature string
|
||||
description string
|
||||
updatedAt string
|
||||
tags string // JSON array
|
||||
}
|
||||
|
||||
rows, err := regDB.Query(`
|
||||
SELECT id, kind, signature, description, updated_at, tags
|
||||
FROM functions
|
||||
WHERE kind IN ('function', 'pipeline')
|
||||
AND NOT (lang = 'cpp' AND file_path LIKE 'cpp/apps/%')
|
||||
ORDER BY updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("emit_capabilities_md: query functions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var allFns []fnRow
|
||||
for rows.Next() {
|
||||
var r fnRow
|
||||
if err := rows.Scan(&r.id, &r.kind, &r.signature, &r.description, &r.updatedAt, &r.tags); err != nil {
|
||||
continue
|
||||
}
|
||||
// Exclude pendiente-usar
|
||||
var tagList []string
|
||||
_ = json.Unmarshal([]byte(r.tags), &tagList)
|
||||
pending := false
|
||||
for _, t := range tagList {
|
||||
if strings.TrimSpace(t) == "pendiente-usar" {
|
||||
pending = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if pending {
|
||||
continue
|
||||
}
|
||||
allFns = append(allFns, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return result, fmt.Errorf("emit_capabilities_md: rows: %w", err)
|
||||
}
|
||||
|
||||
// --- 5. Build Top20 (functions only, kind=function) by calls_total desc ---
|
||||
// When telemetry unavailable, order by updated_at desc (already sorted that way).
|
||||
type scored struct {
|
||||
fnRow
|
||||
score int64 // calls_total if telemetry, else 0 (order preserved)
|
||||
}
|
||||
|
||||
var fnOnly []scored
|
||||
var pipelines []scored
|
||||
for _, r := range allFns {
|
||||
s := scored{fnRow: r, score: callCounts[r.id]}
|
||||
if r.kind == "pipeline" {
|
||||
pipelines = append(pipelines, s)
|
||||
} else {
|
||||
fnOnly = append(fnOnly, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort functions by score desc (stable: updated_at desc is the tiebreaker from query order)
|
||||
if result.TelemetryAvail {
|
||||
stableSort(fnOnly, func(a, b scored) bool { return a.score > b.score })
|
||||
stableSort(pipelines, func(a, b scored) bool { return a.score > b.score })
|
||||
}
|
||||
// else: already in updated_at DESC from SQL
|
||||
|
||||
// Top 20 functions
|
||||
top := min20(fnOnly)
|
||||
for _, s := range top {
|
||||
result.Top20 = append(result.Top20, CapabilityEntry{
|
||||
ID: s.id,
|
||||
Signature: s.signature,
|
||||
Description: truncateDesc(s.description),
|
||||
UpdatedAt: dateOnly(s.updatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// --- 6. Fresh 7d (functions + pipelines updated in last 7 days) ---
|
||||
cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
seen := map[string]bool{}
|
||||
// Mark top20 IDs so we can still include them in fresh if they were touched
|
||||
for _, e := range result.Top20 {
|
||||
seen[e.ID] = false // false = not yet in fresh
|
||||
}
|
||||
for _, r := range allFns {
|
||||
t, err := parseTime(r.updatedAt)
|
||||
if err != nil || t.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
// include any kind (function or pipeline) touched in last 7d
|
||||
result.Fresh7d = append(result.Fresh7d, CapabilityEntry{
|
||||
ID: r.id,
|
||||
Signature: r.signature,
|
||||
Description: truncateDesc(r.description),
|
||||
UpdatedAt: dateOnly(r.updatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// --- 7. Top 5 pipelines ---
|
||||
top5 := min5(pipelines)
|
||||
for _, s := range top5 {
|
||||
result.TopPipelines = append(result.TopPipelines, CapabilityEntry{
|
||||
ID: s.id,
|
||||
Signature: s.signature,
|
||||
Description: truncateDesc(s.description),
|
||||
UpdatedAt: dateOnly(s.updatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RenderCapabilitiesMd formats CapabilitiesMdResult as a markdown string.
|
||||
func RenderCapabilitiesMd(r CapabilitiesMdResult) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("<!-- AUTO-GENERATED by `fn doctor capabilities --emit-claude-md` — do not edit by hand -->\n")
|
||||
sb.WriteString(fmt.Sprintf("<!-- Last refresh: %s -->\n\n", r.GeneratedAt))
|
||||
|
||||
// Section 1: Top 20
|
||||
if !r.TelemetryAvail {
|
||||
sb.WriteString("## Capabilities — Top 20 (by updated_at, telemetry unavailable)\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Capabilities — Top 20 (by calls_total)\n\n")
|
||||
}
|
||||
if len(r.Top20) == 0 {
|
||||
sb.WriteString("_(no functions found)_\n")
|
||||
}
|
||||
for _, e := range r.Top20 {
|
||||
sig := e.Signature
|
||||
if sig == "" {
|
||||
sig = e.ID
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- `%s` — `%s` — %s\n", e.ID, sig, e.Description))
|
||||
}
|
||||
|
||||
// Section 2: Fresh 7d
|
||||
sb.WriteString("\n## Capabilities — Fresh (last 7d)\n\n")
|
||||
if len(r.Fresh7d) == 0 {
|
||||
sb.WriteString("_(no functions updated in the last 7 days)_\n")
|
||||
}
|
||||
for _, e := range r.Fresh7d {
|
||||
sb.WriteString(fmt.Sprintf("- `%s` (%s) — %s\n", e.ID, e.UpdatedAt, e.Description))
|
||||
}
|
||||
|
||||
// Section 3: Top 5 pipelines
|
||||
sb.WriteString("\n## Pipelines — Top 5 (one-shot composites)\n\n")
|
||||
if len(r.TopPipelines) == 0 {
|
||||
sb.WriteString("_(no pipelines found)_\n")
|
||||
}
|
||||
for _, e := range r.TopPipelines {
|
||||
sig := e.Signature
|
||||
if sig == "" {
|
||||
sig = e.ID
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- `%s` — `%s` — %s\n", e.ID, sig, e.Description))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func stableSort[T any](slice []T, less func(a, b T) bool) {
|
||||
// insertion sort — small slices (<500), stable
|
||||
for i := 1; i < len(slice); i++ {
|
||||
for j := i; j > 0 && less(slice[j], slice[j-1]); j-- {
|
||||
slice[j], slice[j-1] = slice[j-1], slice[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min20[T any](s []T) []T {
|
||||
if len(s) <= 20 {
|
||||
return s
|
||||
}
|
||||
return s[:20]
|
||||
}
|
||||
|
||||
func min5[T any](s []T) []T {
|
||||
if len(s) <= 5 {
|
||||
return s
|
||||
}
|
||||
return s[:5]
|
||||
}
|
||||
|
||||
func dateOnly(ts string) string {
|
||||
// "2026-05-14T01:30:00Z" -> "2026-05-14"
|
||||
if len(ts) >= 10 {
|
||||
return ts[:10]
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func parseTime(ts string) (time.Time, error) {
|
||||
formats := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, ts); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse time: %q", ts)
|
||||
}
|
||||
Reference in New Issue
Block a user