fe784d090f
Scripts bash del registry siguen dos patrones: - Con guarda BASH_SOURCE[0]==$0: se auto-invocan al ejecutar directamente - Library-style (sin guarda): definen una función <basename>() pero no la llaman al nivel top-level → bash <script> args produce silencio total El dispatcher en buildBashCommand detecta ahora tres casos: 1. Tiene guarda BASH_SOURCE[0]==$0 → ejecutar directamente (sin cambio) 2. Library-style con función <basename>() → source + llamada explícita: bash -c 'source "$1"; shift; fn_name "$@"' -- <script> [args...] 3. Pipeline top-level (sin función ni guarda) → ejecutar directamente También corrige scan_secrets_in_dirty.sh y git_hook_audit_app_drift.sh para aceptar worktrees git (donde .git es un archivo, no un directorio). Añade bashFunctionName() helper y 4 tests unitarios/integración. Fix reportado en issue 0077 con gradle_unit_test como caso canario. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
272 lines
8.2 KiB
Go
272 lines
8.2 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"fn-registry/registry"
|
|
)
|
|
|
|
func cmdRun(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: fn run <id_or_name> [args...]")
|
|
os.Exit(1)
|
|
}
|
|
|
|
idOrName := args[0]
|
|
passArgs := args[1:]
|
|
|
|
registryRoot := root()
|
|
dbPath := filepath.Join(registryRoot, dbName)
|
|
|
|
db, err := registry.Open(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: cannot open registry: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer db.Close()
|
|
|
|
fn, err := resolveFunction(db, idOrName)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if fn.FilePath == "" {
|
|
fmt.Fprintf(os.Stderr, "error: %s has no file_path in registry\n", fn.ID)
|
|
os.Exit(1)
|
|
}
|
|
|
|
absPath := filepath.Join(registryRoot, fn.FilePath)
|
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
|
fmt.Fprintf(os.Stderr, "error: file not found: %s\n", absPath)
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd, err := buildCommand(fn, db, registryRoot, absPath, passArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
|
|
fmt.Fprintf(os.Stderr, "[fn run] %s (%s/%s) %s\n", fn.ID, fn.Lang, fn.Kind, strings.Join(passArgs, " "))
|
|
|
|
t0 := time.Now()
|
|
runErr := cmd.Run()
|
|
durationMs := time.Since(t0).Milliseconds()
|
|
|
|
exitCode := 0
|
|
errClass := ""
|
|
if runErr != nil {
|
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
errClass = fmt.Sprintf("exit_%d", exitCode)
|
|
} else {
|
|
exitCode = 1
|
|
errClass = "spawn_error"
|
|
}
|
|
}
|
|
logFnRunTelemetry(registryRoot, fn.ID, durationMs, exitCode == 0, errClass)
|
|
|
|
if runErr != nil {
|
|
if errClass == "spawn_error" {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", runErr)
|
|
}
|
|
os.Exit(exitCode)
|
|
}
|
|
}
|
|
|
|
// logFnRunTelemetry inserts a row into call_monitor.operations.db.calls if
|
|
// the database is present. No-op silently otherwise — never blocks the run.
|
|
func logFnRunTelemetry(registryRoot, functionID string, durationMs int64, success bool, errClass string) {
|
|
callDB := filepath.Join(registryRoot, "projects", "fn_monitoring", "apps", "call_monitor", "operations.db")
|
|
if _, err := os.Stat(callDB); err != nil {
|
|
return
|
|
}
|
|
conn, err := sql.Open("sqlite3", callDB+"?_journal_mode=WAL&_busy_timeout=100")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
sessionID := os.Getenv("CLAUDE_SESSION_ID")
|
|
successInt := 0
|
|
if success {
|
|
successInt = 1
|
|
}
|
|
_, _ = conn.Exec(
|
|
`INSERT INTO calls (session_id, function_id, tool_used, args_hash, duration_ms, success, error_class, error_snippet, ts)
|
|
VALUES (?, ?, 'fn_run_cli', '', ?, ?, ?, '', CAST(strftime('%s','now') AS INTEGER))`,
|
|
sessionID, functionID, durationMs, successInt, errClass,
|
|
)
|
|
}
|
|
|
|
func resolveFunction(db *registry.DB, idOrName string) (*registry.Function, error) {
|
|
fn, err := db.GetFunction(idOrName)
|
|
if err == nil {
|
|
return fn, nil
|
|
}
|
|
|
|
fns, err := db.GetFunctionsByName(idOrName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lookup failed: %w", err)
|
|
}
|
|
if len(fns) == 0 {
|
|
return nil, fmt.Errorf("function %q not found (tried as ID and name)", idOrName)
|
|
}
|
|
if len(fns) == 1 {
|
|
return &fns[0], nil
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "ambiguous name %q — found %d matches:\n", idOrName, len(fns))
|
|
for _, f := range fns {
|
|
fmt.Fprintf(&b, " %s (%s/%s)\n", f.ID, f.Lang, f.Kind)
|
|
}
|
|
fmt.Fprintf(&b, "use the full ID to disambiguate")
|
|
return nil, fmt.Errorf("%s", b.String())
|
|
}
|
|
|
|
func buildCommand(fn *registry.Function, db *registry.DB, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
|
switch fn.Lang {
|
|
case "go":
|
|
return buildGoCommand(fn, registryRoot, absPath, args)
|
|
case "py":
|
|
return buildPyRunnerCommand(fn, db, registryRoot, args)
|
|
case "bash":
|
|
return buildBashCommand(absPath, args)
|
|
case "ts":
|
|
return buildTsCommand(registryRoot, absPath, args)
|
|
case "cpp":
|
|
return buildCppCommand(fn, registryRoot, absPath, args)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported lang %q for execution", fn.Lang)
|
|
}
|
|
}
|
|
|
|
func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
|
dir := filepath.Dir(absPath)
|
|
env := append(os.Environ(), "CGO_ENABLED=1")
|
|
|
|
// If directory has main.go → go run . (pipelines and standalone executables)
|
|
mainGo := filepath.Join(dir, "main.go")
|
|
if _, err := os.Stat(mainGo); err == nil {
|
|
cmdArgs := append([]string{"run", "."}, args...)
|
|
cmd := exec.Command("go", cmdArgs...)
|
|
cmd.Dir = dir
|
|
cmd.Env = env
|
|
return cmd, nil
|
|
}
|
|
|
|
// Library code: if it has tests → go test
|
|
if fn.Tested && fn.TestFilePath != "" {
|
|
testAbs := filepath.Join(registryRoot, fn.TestFilePath)
|
|
if _, err := os.Stat(testAbs); err == nil {
|
|
relPkg, _ := filepath.Rel(registryRoot, dir)
|
|
pkgPath := "./" + filepath.ToSlash(relPkg)
|
|
cmdArgs := append([]string{"test", "-v", "-count=1", "-tags", "fts5", pkgPath}, args...)
|
|
cmd := exec.Command("go", cmdArgs...)
|
|
cmd.Dir = registryRoot
|
|
cmd.Env = env
|
|
return cmd, nil
|
|
}
|
|
}
|
|
|
|
// No tests: go vet (compilation check)
|
|
relPkg, _ := filepath.Rel(registryRoot, dir)
|
|
pkgPath := "./" + filepath.ToSlash(relPkg)
|
|
cmdArgs := []string{"vet", "-tags", "fts5", pkgPath}
|
|
cmd := exec.Command("go", cmdArgs...)
|
|
cmd.Dir = registryRoot
|
|
cmd.Env = env
|
|
fmt.Fprintf(os.Stderr, "[fn run] %s is library code without tests — running go vet\n", fn.ID)
|
|
return cmd, nil
|
|
}
|
|
|
|
|
|
// bashFunctionName returns the name of the top-level function defined in the
|
|
// script that matches the file basename (e.g. "gradle_unit_test" for
|
|
// "gradle_unit_test.sh"), or "" if no such function is found.
|
|
//
|
|
// Library-style bash scripts define a function `<basename>()` or
|
|
// `function <basename>` at the top level but do not call it. When executed
|
|
// directly with `bash <script> args...` the function is defined but never
|
|
// invoked, so no output is produced. Detecting this pattern lets the
|
|
// dispatcher source the script and call the function explicitly.
|
|
func bashFunctionName(path, content string) string {
|
|
base := strings.TrimSuffix(filepath.Base(path), ".sh")
|
|
// Match "basename()" or "basename (){" at the start of a line.
|
|
for _, line := range strings.Split(content, "\n") {
|
|
trimmed := strings.TrimLeft(line, " \t")
|
|
if strings.HasPrefix(trimmed, base+"()") ||
|
|
strings.HasPrefix(trimmed, base+" ()") {
|
|
return base
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildBashCommand(absPath string, args []string) (*exec.Cmd, error) {
|
|
dir := filepath.Dir(absPath)
|
|
|
|
content := ""
|
|
if data, err := os.ReadFile(absPath); err == nil {
|
|
content = string(data)
|
|
}
|
|
|
|
// Case 1: script has the self-executing guard — run directly.
|
|
// The guard pattern is: if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
// (many scripts also use BASH_SOURCE[0] only for SCRIPT_DIR — we must
|
|
// match the == "$0" comparison specifically to avoid false positives).
|
|
hasSelfGuard := strings.Contains(content, `BASH_SOURCE[0]}" == "$0"`) ||
|
|
strings.Contains(content, `BASH_SOURCE[0]}" = "$0"`)
|
|
if hasSelfGuard {
|
|
cmdArgs := append([]string{absPath}, args...)
|
|
cmd := exec.Command("bash", cmdArgs...)
|
|
cmd.Dir = dir
|
|
return cmd, nil
|
|
}
|
|
|
|
// Case 2: library-style script — defines a function `<basename>()` at
|
|
// the top level but never calls it. Source the script and call the
|
|
// function explicitly so stdout/stderr reach the caller.
|
|
//
|
|
// bash -c 'source "$1"; shift; fn_name "$@"' -- <script> [args...]
|
|
if fnName := bashFunctionName(absPath, content); fnName != "" {
|
|
inline := fmt.Sprintf(`source "$1"; shift; %s "$@"`, fnName)
|
|
cmdArgs := append([]string{"-c", inline, "--", absPath}, args...)
|
|
cmd := exec.Command("bash", cmdArgs...)
|
|
cmd.Dir = dir
|
|
return cmd, nil
|
|
}
|
|
|
|
// Case 3: top-level pipeline script — executes code directly without a
|
|
// wrapping function (e.g. propose_capability_groups.sh). Run as-is.
|
|
cmdArgs := append([]string{absPath}, args...)
|
|
cmd := exec.Command("bash", cmdArgs...)
|
|
cmd.Dir = dir
|
|
return cmd, nil
|
|
}
|
|
|
|
func buildTsCommand(registryRoot, absPath string, args []string) (*exec.Cmd, error) {
|
|
tsxBin := filepath.Join(registryRoot, "frontend", "node_modules", ".bin", "tsx")
|
|
if _, err := os.Stat(tsxBin); os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("tsx not found — run: cd frontend && pnpm add -D tsx")
|
|
}
|
|
|
|
cmdArgs := append([]string{absPath}, args...)
|
|
cmd := exec.Command(tsxBin, cmdArgs...)
|
|
cmd.Dir = filepath.Dir(absPath)
|
|
return cmd, nil
|
|
}
|