fix(fn-run): propagar stdout/stderr de bash functions library-style

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>
This commit is contained in:
2026-05-15 13:52:23 +02:00
parent 88119ee1b2
commit fe784d090f
4 changed files with 245 additions and 3 deletions
+58 -1
View File
@@ -194,10 +194,67 @@ func buildGoCommand(fn *registry.Function, registryRoot, absPath string, args []
}
// 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 = filepath.Dir(absPath)
cmd.Dir = dir
return cmd, nil
}