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:
@@ -6,7 +6,9 @@
|
|||||||
scan_secrets_in_dirty() {
|
scan_secrets_in_dirty() {
|
||||||
local repo_dir="${1:-.}"
|
local repo_dir="${1:-.}"
|
||||||
|
|
||||||
if [[ ! -d "$repo_dir/.git" ]]; then
|
# Accept both regular repos (.git is a directory) and worktrees (.git is a
|
||||||
|
# file containing "gitdir: ..." pointer).
|
||||||
|
if [[ ! -d "$repo_dir/.git" && ! -f "$repo_dir/.git" ]]; then
|
||||||
echo "scan_secrets_in_dirty: '$repo_dir' no es un repo git" >&2
|
echo "scan_secrets_in_dirty: '$repo_dir' no es un repo git" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ git_hook_audit_app_drift() {
|
|||||||
echo "ERROR: repo_dir required" >&2
|
echo "ERROR: repo_dir required" >&2
|
||||||
return 2
|
return 2
|
||||||
fi
|
fi
|
||||||
if [[ ! -d "$repo_dir/.git" ]]; then
|
# Accept both regular repos (.git is a directory) and worktrees (.git is a
|
||||||
|
# file containing "gitdir: ..." pointer).
|
||||||
|
if [[ ! -d "$repo_dir/.git" && ! -f "$repo_dir/.git" ]]; then
|
||||||
echo "ERROR: $repo_dir is not a git repo" >&2
|
echo "ERROR: $repo_dir is not a git repo" >&2
|
||||||
return 2
|
return 2
|
||||||
fi
|
fi
|
||||||
|
|||||||
+58
-1
@@ -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) {
|
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...)
|
cmdArgs := append([]string{absPath}, args...)
|
||||||
cmd := exec.Command("bash", cmdArgs...)
|
cmd := exec.Command("bash", cmdArgs...)
|
||||||
cmd.Dir = filepath.Dir(absPath)
|
cmd.Dir = dir
|
||||||
return cmd, nil
|
return cmd, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBashFunctionName verifies detection of library-style bash scripts
|
||||||
|
// that define a function matching the file basename.
|
||||||
|
func TestBashFunctionName(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
content string
|
||||||
|
wantFn string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "library-style: defines matching function",
|
||||||
|
filename: "gradle_unit_test.sh",
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/gradle_run.sh"
|
||||||
|
|
||||||
|
gradle_unit_test() {
|
||||||
|
local project_dir="$1"
|
||||||
|
echo "running tests in $project_dir"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantFn: "gradle_unit_test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "library-style: defines matching function with space before parens",
|
||||||
|
filename: "port_kill.sh",
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
port_kill () {
|
||||||
|
echo "killing port $1"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantFn: "port_kill",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "top-level pipeline: no matching function",
|
||||||
|
filename: "propose_capability_groups.sh",
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
# inline pipeline logic
|
||||||
|
find_root() { echo "root"; }
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
`,
|
||||||
|
wantFn: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-executing (has guard): no separate function detection needed",
|
||||||
|
filename: "rsync_deploy.sh",
|
||||||
|
content: `#!/usr/bin/env bash
|
||||||
|
rsync_deploy() {
|
||||||
|
echo "deploying"
|
||||||
|
}
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
rsync_deploy "$@"
|
||||||
|
fi
|
||||||
|
`,
|
||||||
|
// bashFunctionName should still return the function name — the
|
||||||
|
// caller (buildBashCommand) decides which path to take based on
|
||||||
|
// the guard, not this helper.
|
||||||
|
wantFn: "rsync_deploy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := bashFunctionName("/tmp/"+tc.filename, tc.content)
|
||||||
|
if got != tc.wantFn {
|
||||||
|
t.Errorf("bashFunctionName(%q) = %q, want %q", tc.filename, got, tc.wantFn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildBashCommand_LibraryStyle verifies that a library-style bash script
|
||||||
|
// (defines a function but has no self-invocation guard) is run by sourcing
|
||||||
|
// the script and calling the function — producing real stdout output.
|
||||||
|
func TestBuildBashCommand_LibraryStyle(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write a library-style script: defines a function, no guard.
|
||||||
|
scriptPath := filepath.Join(dir, "say_hello.sh")
|
||||||
|
scriptContent := `#!/usr/bin/env bash
|
||||||
|
say_hello() {
|
||||||
|
echo "hello from $1"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := buildBashCommand(scriptPath, []string{"world"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildBashCommand error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cmd.Output() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.TrimSpace(string(out))
|
||||||
|
want := "hello from world"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("output = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildBashCommand_SelfGuard verifies that a script with the BASH_SOURCE
|
||||||
|
// guard is run directly (not via source+call) and still produces output.
|
||||||
|
func TestBuildBashCommand_SelfGuard(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(dir, "say_hi.sh")
|
||||||
|
scriptContent := `#!/usr/bin/env bash
|
||||||
|
say_hi() {
|
||||||
|
echo "hi from $1"
|
||||||
|
}
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||||
|
say_hi "$@"
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := buildBashCommand(scriptPath, []string{"guard"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildBashCommand error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cmd.Output() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.TrimSpace(string(out))
|
||||||
|
want := "hi from guard"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("output = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildBashCommand_TopLevelPipeline verifies that a pipeline script that
|
||||||
|
// runs top-level code directly (no function, no guard) produces output.
|
||||||
|
func TestBuildBashCommand_TopLevelPipeline(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
scriptPath := filepath.Join(dir, "my_pipeline.sh")
|
||||||
|
scriptContent := `#!/usr/bin/env bash
|
||||||
|
echo "pipeline ran with $# args: $@"
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, err := buildBashCommand(scriptPath, []string{"a", "b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildBashCommand error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cmd.Output() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.TrimSpace(string(out))
|
||||||
|
want := "pipeline ran with 2 args: a b"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("output = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user