4e8b5af6c4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
319 lines
8.8 KiB
Go
319 lines
8.8 KiB
Go
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)
|
|
}
|